Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8de3314
Add comprehensive WebDAV storage tests
claude Nov 9, 2025
7177d10
Add WebDAV Docker infrastructure for integration tests
claude Nov 9, 2025
0bb3086
Fix WebDAV container health check failures
claude Nov 9, 2025
0a2cdc7
Fix WebDAV environment variable names
claude Nov 9, 2025
81c096c
Fix WebDAV health check to follow redirects
claude Nov 9, 2025
e6d2369
Fix WebDAV health check to accept 200 or 401 responses
claude Nov 9, 2025
f081b9f
Simplify WebDAV health check for better reliability
claude Nov 9, 2025
6c44a12
Switch to maltokyo/docker-nginx-webdav for faster startup
claude Nov 9, 2025
f2d03bb
Fix maltokyo WebDAV environment variable names
claude Nov 9, 2025
c60c094
Fix WebDAV health check to use pgrep instead of curl
claude Nov 9, 2025
6ccc3c9
Switch WebDAV health check to port check and add diagnostics
claude Nov 9, 2025
7c97583
Attempt to fix WebDav tests
Menelion Jan 4, 2026
0f00b31
Another attempt at fixing WebDav tests
Menelion Jan 4, 2026
e854de4
Fix CS
Menelion Jan 4, 2026
52976b2
another attempt
Menelion Jan 5, 2026
50d4987
Fix WebDAV directory creation to ensure parent directories exist
Menelion Jan 5, 2026
ac6f9fa
Fix CS
Menelion Jan 5, 2026
8ea13a7
Improve WebDAV directory creation robustness
Menelion Jan 5, 2026
cd3cd1f
Fix CS
Menelion Jan 5, 2026
455e57b
Fix WebDAV CI configuration with better container setup
Menelion Jan 5, 2026
d583cc0
Fix invalid WebDAV Docker image tag in CI workflow
Menelion Jan 5, 2026
e0d73b3
Fix Apache ServerName error in bytemark/webdav container
Menelion Jan 5, 2026
72a1c98
Improve WebDAV container health check without relying on curl
Menelion Jan 5, 2026
5a57ccb
Switch to nginx-based WebDAV container for better CI reliability
Menelion Jan 5, 2026
c2e1efa
Meow
Menelion Jan 5, 2026
39f1350
Moew2
Menelion Jan 5, 2026
f3fcf27
Meow3
Menelion Jan 5, 2026
a16192b
Meow4
Menelion Jan 5, 2026
c84e6fb
Meow5
Menelion Jan 5, 2026
720253b
Rerun wf
Menelion Jan 5, 2026
64bc84c
Meow6
Menelion Jan 5, 2026
3e06e68
Moew7
Menelion Jan 5, 2026
ad2adb7
Meow8
Menelion Jan 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
"Bash(find:*)",
"Bash(dotnet build:*)",
"Bash(dotnet clean:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet test:*)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(docker compose:*)",
"Bash(docker exec:*)"
],
"deny": []
}
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,28 @@ jobs:
DEBUG: 0
EDGE_PORT: 4566


steps:
- uses: actions/checkout@v6

- name: Start WebDAV server
run: |
echo "=== Starting WebDAV server ==="
docker run -d --name webdav-server -p 8080:8080 \
eclipse-temurin:11-jre bash -c "
apt-get update && apt-get install -y curl wget &&
wget -O webdav-server.jar https://repo1.maven.org/maven2/io/github/atetzner/webdav-embedded-server/0.2.1/webdav-embedded-server-0.2.1.jar &&
mkdir -p /webdav &&
java -jar webdav-server.jar --port 8080 --directory /webdav
"

- name: Wait for services to be ready
run: |
echo "=== Waiting for services to be ready ==="
sleep 15
echo "=== Testing WebDAV server ==="
curl -f http://localhost:8080/ || echo "WebDAV not ready yet, continuing..."

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
Expand Down Expand Up @@ -91,3 +111,7 @@ jobs:
S3_TEST_SECRET_KEY: test
S3_TEST_ENDPOINT: http://localhost:4566
S3_TEST_PREFIX: sharpsync-tests
WEBDAV_TEST_URL: http://localhost:8080/
WEBDAV_TEST_USER: ""
WEBDAV_TEST_PASS: ""
WEBDAV_TEST_ROOT: ""
17 changes: 17 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,24 @@ services:
timeout: 5s
retries: 5

webdav:
image: maltokyo/docker-nginx-webdav:latest
ports:
- "8080:80"
environment:
USERNAME: testuser
PASSWORD: testpass
volumes:
- webdav-data:/media/data
healthcheck:
test: ["CMD", "sh", "-c", "test -f /var/run/nginx.pid"]
interval: 10s
timeout: 5s
retries: 10
start_period: 60s

volumes:
sftp-data:
ftp-data:
localstack-data:
webdav-data:
25 changes: 23 additions & 2 deletions scripts/run-integration-tests.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack)
# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack, WebDAV)
# Usage: .\scripts\run-integration-tests.ps1 [-TestFilter "filter"]

param(
Expand All @@ -10,7 +10,7 @@ $ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir

Write-Host "🚀 Starting test servers (SFTP, FTP, S3/LocalStack)..." -ForegroundColor Cyan
Write-Host "🚀 Starting test servers (SFTP, FTP, S3/LocalStack, WebDAV)..." -ForegroundColor Cyan
Set-Location $ProjectRoot
docker-compose -f docker-compose.test.yml up -d

Expand Down Expand Up @@ -73,6 +73,22 @@ while ($elapsed -lt $timeout) {
Write-Host " Waiting... ($elapsed`s/$timeout`s)" -ForegroundColor Gray
}

Write-Host "⏳ Waiting for WebDAV server to be ready..." -ForegroundColor Yellow
$elapsed = 0
$isHealthy = $false

while ($elapsed -lt $timeout) {
$containerStatus = docker-compose -f docker-compose.test.yml ps webdav
if ($containerStatus -match "healthy") {
Write-Host "✅ WebDAV server is ready" -ForegroundColor Green
$isHealthy = $true
break
}
Start-Sleep -Seconds 2
$elapsed += 2
Write-Host " Waiting... ($elapsed`s/$timeout`s)" -ForegroundColor Gray
}

# Create S3 test bucket in LocalStack
Write-Host "📦 Creating S3 test bucket..." -ForegroundColor Cyan
docker-compose -f docker-compose.test.yml exec -T localstack awslocal s3 mb s3://test-bucket 2>$null
Expand All @@ -96,6 +112,11 @@ $env:S3_TEST_SECRET_KEY = "test"
$env:S3_TEST_ENDPOINT = "http://localhost:4566"
$env:S3_TEST_PREFIX = "sharpsync-tests"

$env:WEBDAV_TEST_URL = "http://localhost:8080/"
$env:WEBDAV_TEST_USER = "testuser"
$env:WEBDAV_TEST_PASS = "testpass"
$env:WEBDAV_TEST_ROOT = ""

Write-Host "🧪 Running tests..." -ForegroundColor Cyan

# Run tests with optional filter
Expand Down
21 changes: 19 additions & 2 deletions scripts/run-integration-tests.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/bin/bash

# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack)
# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack, WebDAV)
# Usage: ./scripts/run-integration-tests.sh [test-filter]

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

echo "🚀 Starting test servers (SFTP, FTP, S3/LocalStack)..."
echo "🚀 Starting test servers (SFTP, FTP, S3/LocalStack, WebDAV)..."
docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" up -d

echo "⏳ Waiting for SFTP server to be ready..."
Expand Down Expand Up @@ -58,6 +58,18 @@ while [ $ELAPSED -lt $TIMEOUT ]; do
echo " Waiting... (${ELAPSED}s/${TIMEOUT}s)"
done

echo "⏳ Waiting for WebDAV server to be ready..."
ELAPSED=0
while [ $ELAPSED -lt $TIMEOUT ]; do
if docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" ps webdav | grep -q "healthy"; then
echo "✅ WebDAV server is ready"
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
echo " Waiting... (${ELAPSED}s/${TIMEOUT}s)"
done

# Create S3 test bucket in LocalStack
echo "📦 Creating S3 test bucket..."
docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" exec -T localstack awslocal s3 mb s3://test-bucket 2>/dev/null || true
Expand All @@ -81,6 +93,11 @@ export S3_TEST_SECRET_KEY=test
export S3_TEST_ENDPOINT=http://localhost:4566
export S3_TEST_PREFIX=sharpsync-tests

export WEBDAV_TEST_URL=http://localhost:8080/
export WEBDAV_TEST_USER=testuser
export WEBDAV_TEST_PASS=testpass
export WEBDAV_TEST_ROOT=""

echo "🧪 Running tests..."
cd "$PROJECT_ROOT"

Expand Down
102 changes: 73 additions & 29 deletions src/SharpSync/Storage/WebDavStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -522,27 +522,63 @@ public async Task CreateDirectoryAsync(string path, CancellationToken cancellati
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");

var fullPath = GetFullPath(path);
// Normalize the path
path = path.Replace('\\', '/').Trim('/');

await ExecuteWithRetry(async () => {
// Check if directory already exists
var existsResult = await _client.Propfind(fullPath, new PropfindParameters {
CancellationToken = cancellationToken
});
// Handle empty path
if (string.IsNullOrEmpty(path)) {
return; // Root directory already exists
}

if (existsResult.IsSuccessful) {
return true; // Directory already exists
}
// Create all parent directories first
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
var currentPath = "";

var result = await _client.Mkcol(fullPath, new MkColParameters {
CancellationToken = cancellationToken
});
for (int i = 0; i < segments.Length; i++) {
currentPath = i == 0 ? segments[i] : $"{currentPath}/{segments[i]}";
var fullPath = GetFullPath(currentPath);

await ExecuteWithRetry(async () => {
try {
// Check if directory already exists
var existsResult = await _client.Propfind(fullPath, new PropfindParameters {
RequestType = PropfindRequestType.NamedProperties,
CancellationToken = cancellationToken
});

if (!result.IsSuccessful && result.StatusCode != 405) // 405 = already exists
throw new HttpRequestException($"Directory creation failed: {result.StatusCode}");
if (existsResult.IsSuccessful) {
// Check if it's actually a collection/directory
var resource = existsResult.Resources?.FirstOrDefault();
if (resource != null && resource.IsCollection) {
return true; // Directory already exists
}
}
} catch {
// PROPFIND failed, directory probably doesn't exist
}

return true;
}, cancellationToken);
// Try to create the directory
var result = await _client.Mkcol(fullPath, new MkColParameters {
CancellationToken = cancellationToken
});

if (result.IsSuccessful || result.StatusCode == 201) {
return true; // Created successfully
}

if (result.StatusCode == 405) {
// Method Not Allowed - likely means it already exists as a file
return true;
}

if (result.StatusCode == 409) {
// Conflict - parent doesn't exist, but we're creating in order so this shouldn't happen
throw new HttpRequestException($"Parent directory doesn't exist for {currentPath}");
}

throw new HttpRequestException($"Directory creation failed for {currentPath}: {result.StatusCode} {result.Description}");
}, cancellationToken);
}
}

/// <summary>
Expand Down Expand Up @@ -615,14 +651,19 @@ public async Task<bool> ExistsAsync(string path, CancellationToken cancellationT

var fullPath = GetFullPath(path);

return await ExecuteWithRetry(async () => {
var result = await _client.Propfind(fullPath, new PropfindParameters {
RequestType = PropfindRequestType.AllProperties,
CancellationToken = cancellationToken
});
try {
return await ExecuteWithRetry(async () => {
var result = await _client.Propfind(fullPath, new PropfindParameters {
RequestType = PropfindRequestType.NamedProperties,
CancellationToken = cancellationToken
});

return result.IsSuccessful;
}, cancellationToken);
return result.IsSuccessful && result.StatusCode != 404;
}, cancellationToken);
} catch {
// If PROPFIND fails with an exception, assume the item doesn't exist
return false;
}
}

/// <summary>
Expand Down Expand Up @@ -821,15 +862,18 @@ private async Task<ServerCapabilities> DetectServerCapabilitiesAsync(Cancellatio
}

private string GetFullPath(string relativePath) {
if (string.IsNullOrEmpty(relativePath) || relativePath == "/")
return string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl}/{RootPath}";
if (string.IsNullOrEmpty(relativePath) || relativePath == "/") {
var basePath = string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl.TrimEnd('/')}/{RootPath.Trim('/')}";
return basePath.TrimEnd('/') + "/";
}

relativePath = relativePath.Trim('/');

if (string.IsNullOrEmpty(RootPath))
return $"{_baseUrl}/{relativePath}";
else
return $"{_baseUrl}/{RootPath}/{relativePath}";
if (string.IsNullOrEmpty(RootPath)) {
return $"{_baseUrl.TrimEnd('/')}/{relativePath}";
} else {
return $"{_baseUrl.TrimEnd('/')}/{RootPath.Trim('/')}/{relativePath}";
}
}

private string GetRelativePath(string fullUrl) {
Expand Down
1 change: 1 addition & 0 deletions tests/SharpSync.Tests/SharpSync.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
2 changes: 2 additions & 0 deletions tests/SharpSync.Tests/Storage/FtpStorageTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Oire.SharpSync.Tests.Fixtures;

namespace Oire.SharpSync.Tests.Storage;

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions tests/SharpSync.Tests/Storage/S3StorageTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Oire.SharpSync.Tests.Fixtures;

namespace Oire.SharpSync.Tests.Storage;

/// <summary>
Expand Down
8 changes: 2 additions & 6 deletions tests/SharpSync.Tests/Storage/SftpStorageTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Oire.SharpSync.Tests.Fixtures;

namespace Oire.SharpSync.Tests.Storage;

/// <summary>
Expand Down Expand Up @@ -647,9 +649,3 @@ public async Task GetItemAsync_IncludesPermissions() {
#endregion
}

/// <summary>
/// Exception to indicate test should be skipped
/// </summary>
public class SkipException: Exception {
public SkipException(string message) : base(message) { }
}
Loading