diff --git a/.github/scripts/verify-published-packages.py b/.github/scripts/verify-published-packages.py new file mode 100644 index 0000000..bbc6fd1 --- /dev/null +++ b/.github/scripts/verify-published-packages.py @@ -0,0 +1,68 @@ +"""Verify every packed NuGet artifact against the public feed before declaring a release.""" +import argparse +import io +import re +import time +import subprocess +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + + +def metadata(package_bytes): + with zipfile.ZipFile(io.BytesIO(package_bytes)) as archive: + names = [name for name in archive.namelist() if name.endswith(".nuspec")] + if len(names) != 1: + raise ValueError("Package must contain one nuspec") + root = ET.fromstring(archive.read(names[0])) + package = root.find("{*}metadata") + repository = package.find("{*}repository") + return (package.findtext("{*}id"), package.findtext("{*}version"), + repository.get("commit") if repository is not None else None) + + +def verify(directory, commit, timeout): + packages = sorted(directory.glob("*.nupkg")) + if not packages: + raise ValueError("No packages to verify") + pending = {} + for package in packages: + identity = metadata(package.read_bytes()) + package_id, version, source_commit = identity + if not re.fullmatch(r"ManagedCode\.Storage(?:\.[A-Za-z0-9_.-]+)?", package_id): + raise ValueError(f"Unexpected package identity: {package_id}") + if source_commit != commit: + raise ValueError(f"{package_id}: packed repository commit does not match the release commit") + pending[package_id] = identity + deadline = time.monotonic() + timeout + while pending: + for package_id, identity in list(pending.items()): + _, version, _ = identity + name = package_id.lower() + url = f"https://api.nuget.org/v3-flatcontainer/{name}/{version}/{name}.{version}.nupkg" + try: + response = subprocess.run(["curl", "--fail", "--silent", "--show-error", "--max-time", "15", + "--header", "Cache-Control: no-cache", url], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) + published = metadata(response.stdout) + if published != identity: + raise ValueError(f"{package_id}: published identity/version/commit differs from the packed artifact") + print(f"Verified {package_id} {version} at {commit}", flush=True) + del pending[package_id] + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + print(f"Waiting for {package_id}: {type(error).__name__}", flush=True) + if not pending: + break + if time.monotonic() >= deadline: + raise TimeoutError("Packages unavailable before the publication deadline: " + ", ".join(pending)) + time.sleep(min(20, max(0, deadline - time.monotonic()))) + print(f"Verified all {len(packages)} published packages", flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("commit") + parser.add_argument("--timeout-seconds", type=int, default=600) + arguments = parser.parse_args() + verify(arguments.directory, arguments.commit, arguments.timeout_seconds) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b34d91b..6a68470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,14 +40,25 @@ jobs: run: dotnet playwright -p Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj install chromium - name: Test - run: dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --no-build --verbosity normal --filter "Category!=BrowserStress" --collect:"XPlat Code Coverage" + run: dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --no-build --verbosity normal --filter "Category!=BrowserStress" /p:CollectCoverage=true --logger "trx;LogFileName=storage-tests.trx" + + - name: Preserve coverage and test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: storage-quality-results + path: | + artifacts/coverage/coverage.* + Tests/ManagedCode.Storage.Tests/*.trx + if-no-files-found: error - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./**/coverage.cobertura.xml - fail_ci_if_error: false + files: artifacts/coverage/coverage.cobertura.xml + disable_search: true + fail_ci_if_error: true browser-stress: name: browser-stress diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67c0c7d..0a5bec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: id: version run: | VERSION=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Version from Directory.Build.props: $VERSION" - name: Restore dependencies @@ -45,8 +45,18 @@ jobs: - name: Install Playwright browser run: dotnet playwright -p Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj install chromium - - name: Test - run: dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --no-build --verbosity normal --filter "Category!=BrowserStress" + - name: Test with production coverage + run: dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --no-build --verbosity normal --filter "Category!=BrowserStress" /p:CollectCoverage=true --logger "trx;LogFileName=storage-tests.trx" + + - name: Preserve coverage and test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-quality-results + path: | + artifacts/coverage/coverage.* + Tests/ManagedCode.Storage.Tests/*.trx + if-no-files-found: error - name: Pack NuGet packages run: dotnet pack ManagedCode.Storage.slnx --configuration Release --no-build -p:IncludeSymbols=false -p:SymbolPackageFormat=snupkg --output ./artifacts @@ -89,6 +99,7 @@ jobs: publish-nuget: name: Publish to NuGet + timeout-minutes: 20 needs: - build - browser-stress @@ -116,7 +127,6 @@ jobs: - name: Publish to NuGet id: publish - continue-on-error: true run: | set +e OUTPUT="" @@ -144,13 +154,16 @@ jobs: done if [ "$PUBLISHED" = true ] || echo "$OUTPUT" | grep -q "Your package was pushed"; then - echo "published=true" >> $GITHUB_OUTPUT + echo "published=true" >> "$GITHUB_OUTPUT" echo "At least one package was successfully published" else - echo "published=false" >> $GITHUB_OUTPUT + echo "published=false" >> "$GITHUB_OUTPUT" echo "No new packages were published (all already exist)" fi + - name: Verify every package on the public feed + run: python3 .github/scripts/verify-published-packages.py ./artifacts "${{ github.sha }}" + create-release: name: Create GitHub Release and Tag needs: publish-nuget @@ -181,12 +194,12 @@ jobs: if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists" - echo "tag_exists=true" >> $GITHUB_OUTPUT + echo "tag_exists=true" >> "$GITHUB_OUTPUT" else echo "Creating tag $TAG" git tag -a "$TAG" -m "Release $VERSION" git push origin "$TAG" - echo "tag_exists=false" >> $GITHUB_OUTPUT + echo "tag_exists=false" >> "$GITHUB_OUTPUT" fi - name: Get previous tag @@ -197,7 +210,7 @@ jobs: if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -n1 || echo "") fi - echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" echo "Current tag: $CURRENT_TAG" echo "Previous tag: $PREVIOUS_TAG" @@ -205,53 +218,49 @@ jobs: id: release_notes run: | VERSION="${{ needs.publish-nuget.outputs.version }}" - CURRENT_TAG="v$VERSION" PREVIOUS_TAG="${{ steps.prev_tag.outputs.previous_tag }}" - echo "# Release $VERSION" > release_notes.md - echo "" >> release_notes.md - echo "Released on $(date +'%Y-%m-%d')" >> release_notes.md - echo "" >> release_notes.md - - if [ -n "$PREVIOUS_TAG" ]; then - echo "## 📋 Changes since $PREVIOUS_TAG" >> release_notes.md - echo "" >> release_notes.md - - echo "### ✨ Features" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^feat" --grep="^feature" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 🐛 Bug Fixes" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^fix" --grep="^bugfix" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 📚 Documentation" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^docs" --grep="^doc" >> release_notes.md || true - echo "" >> release_notes.md - - echo "### 🔧 Other Changes" >> release_notes.md - git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" >> release_notes.md || true - echo "" >> release_notes.md - else - echo "## 🎉 Initial Release" >> release_notes.md - echo "" >> release_notes.md - echo "### Recent Changes" >> release_notes.md - git log --pretty=format:"- %s (%h)" --max-count=20 >> release_notes.md - echo "" >> release_notes.md - fi - - echo "" >> release_notes.md - echo "## 📦 NuGet Packages" >> release_notes.md - echo "" >> release_notes.md - for package in ./artifacts/*.nupkg; do - PACKAGE_NAME=$(basename "$package" .nupkg) - BASE_NAME=$(echo "$PACKAGE_NAME" | sed "s/\.$VERSION//") - echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" >> release_notes.md - done + { + echo "# Release $VERSION" + echo "" + echo "Released on $(date +'%Y-%m-%d')" + echo "" + + if [ -n "$PREVIOUS_TAG" ]; then + echo "## 📋 Changes since $PREVIOUS_TAG" + echo "" + echo "### ✨ Features" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^feat" --grep="^feature" || true + echo "" + echo "### 🐛 Bug Fixes" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^fix" --grep="^bugfix" || true + echo "" + echo "### 📚 Documentation" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --grep="^docs" --grep="^doc" || true + echo "" + echo "### 🔧 Other Changes" + git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG..HEAD" --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" || true + echo "" + else + echo "## 🎉 Initial Release" + echo "" + echo "### Recent Changes" + git log --pretty=format:"- %s (%h)" --max-count=20 || true + echo "" + fi - echo "" >> release_notes.md - echo "---" >> release_notes.md - echo "*This release was automatically created by GitHub Actions*" >> release_notes.md + echo "" + echo "## 📦 NuGet Packages" + echo "" + for package in ./artifacts/*.nupkg; do + PACKAGE_NAME=$(basename "$package" .nupkg) + BASE_NAME="${PACKAGE_NAME%."$VERSION"}" + echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" + done + echo "" + echo "---" + echo "*This release was automatically created by GitHub Actions*" + } > release_notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/AGENTS.md b/AGENTS.md index 787b2ce..2d1d741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,15 +146,15 @@ If the stack is `.NET`, follow these skill-management rules explicitly: - `build`: `dotnet build ManagedCode.Storage.slnx` - `test`: `dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress"` - `browser-stress`: `dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category=BrowserStress"` -- `coverage`: `dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress" /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover` +- `coverage`: `dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress" /p:CollectCoverage=true` - `format`: `dotnet format ManagedCode.Storage.slnx` Toolchain notes: -- Tests run on `xUnit` over `VSTest` via `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio`. +- Tests run on xUnit.net Core Framework v3 4.0.2 using `xunit.v3.mtp-off`, over `VSTest` via `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio` 4.0.1. MTP is intentionally disabled for the canonical Coverlet MSBuild flow. - `format` intentionally applies fixes instead of running in verify-only mode. - CI verifies formatting with `dotnet format ManagedCode.Storage.slnx --verify-no-changes`. -- `coverage` uses `coverlet.msbuild` through `dotnet test` MSBuild properties. +- `coverage` uses only `coverlet.msbuild`, identically locally and in CI. It writes JSON, Cobertura, and OpenCover to `artifacts/coverage/coverage.*` and enforces total production-package line coverage >=85% and branch coverage >=70%. Only the two executable browser test-host assemblies are excluded; all 19 published packages, including Browser and TestFakes, remain in the metric. - Architecture dependency rules use `NetArchTest.Rules` inside `Tests/ManagedCode.Storage.Tests/Architecture/` and run through the normal `test` command. - Explicit `LangVersion` should only be introduced if a project intentionally differs from the SDK default. diff --git a/Directory.Build.props b/Directory.Build.props index 0bc565f..87143f2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,8 +29,8 @@ https://github.com/managedcode/Storage https://github.com/managedcode/Storage Managed Code - Storage - 10.1.3 - 10.1.3 + 10.1.4 + 10.1.4 diff --git a/Directory.Packages.props b/Directory.Packages.props index 525b62b..d767884 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,6 @@ - @@ -48,7 +47,7 @@ - + diff --git a/Integraions/ManagedCode.Storage.Client/AGENTS.md b/Integraions/ManagedCode.Storage.Client/AGENTS.md index 79a46a9..4bde37f 100644 --- a/Integraions/ManagedCode.Storage.Client/AGENTS.md +++ b/Integraions/ManagedCode.Storage.Client/AGENTS.md @@ -48,3 +48,7 @@ Parent: `../../AGENTS.md` - Keep the client contract aligned with `ManagedCode.Storage.Server/Controllers/IStorageController.cs`. - Do not introduce provider-specific branches; this client stays storage-agnostic. - Preserve stream-first transfer and cancellation behavior so large-file flows remain viable. + +## Scoped Coverage Repair Maintainability Exception + +- `StorageClient`, including its existing chunk transfer method already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Integraions/ManagedCode.Storage.Client/StorageClient.cs b/Integraions/ManagedCode.Storage.Client/StorageClient.cs index ef9579d..8c72992 100644 --- a/Integraions/ManagedCode.Storage.Client/StorageClient.cs +++ b/Integraions/ManagedCode.Storage.Client/StorageClient.cs @@ -190,6 +190,15 @@ public async Task> UploadLargeFile(Stream file, string uploadApiUrl var message = await response.Content.ReadAsStringAsync(cancellationToken); return Result.Fail(response.StatusCode, message); } + if (response.Content.Headers.ContentType?.MediaType == "application/json") + { + var chunkResult = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (chunkResult is null || chunkResult.Value.IsFailed) + { + return Result.Fail((HttpStatusCode)(chunkResult?.Problem?.StatusCode ?? 500), + chunkResult?.Problem?.Title ?? "Chunk upload response is missing"); + } + } } transmitted += bytesRead; diff --git a/Integraions/ManagedCode.Storage.Orleans/Storage/ManagedCodeStoredGrainState.cs b/Integraions/ManagedCode.Storage.Orleans/Storage/ManagedCodeStoredGrainState.cs index cd62e70..10e22dc 100644 --- a/Integraions/ManagedCode.Storage.Orleans/Storage/ManagedCodeStoredGrainState.cs +++ b/Integraions/ManagedCode.Storage.Orleans/Storage/ManagedCodeStoredGrainState.cs @@ -6,5 +6,7 @@ public sealed class ManagedCodeStoredGrainState public string? ETag { get; set; } + [System.ComponentModel.DefaultValue(true)] + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public bool RecordExists { get; set; } = true; } diff --git a/Integraions/ManagedCode.Storage.Server/AGENTS.md b/Integraions/ManagedCode.Storage.Server/AGENTS.md index 6cfa1be..75048b7 100644 --- a/Integraions/ManagedCode.Storage.Server/AGENTS.md +++ b/Integraions/ManagedCode.Storage.Server/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Base controllers and hubs must stay minimal and customizable; do not hardcode routes, auth policy, or provider-specific behavior. - Preserve streaming and chunked-upload paths as incremental flows; do not add whole-file buffering. - Keep server contracts aligned with both HTTP and SignalR client packages. + +## Scoped Coverage Repair Maintainability Exception + +- the existing `StorageControllerBase` source file already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Integraions/ManagedCode.Storage.Server/Controllers/StorageControllerBase.cs b/Integraions/ManagedCode.Storage.Server/Controllers/StorageControllerBase.cs index 61d494e..2ebdfe3 100644 --- a/Integraions/ManagedCode.Storage.Server/Controllers/StorageControllerBase.cs +++ b/Integraions/ManagedCode.Storage.Server/Controllers/StorageControllerBase.cs @@ -157,7 +157,7 @@ public virtual async Task DownloadBytesAsync([FromRoute] string pa /// [HttpPost("upload-chunks/upload"), ProducesResponseType(typeof(Result), StatusCodes.Status200OK)] - public virtual async Task UploadChunkAsync([FromForm] FileUploadPayload payload, CancellationToken cancellationToken) + public virtual async Task UploadChunkAsync([FromForm(Name = "")] FileUploadPayload payload, CancellationToken cancellationToken) { if (payload?.File is null) { diff --git a/Integraions/ManagedCode.Storage.Server/Extensions/File/BrowserFileExtensions.cs b/Integraions/ManagedCode.Storage.Server/Extensions/File/BrowserFileExtensions.cs index ac49522..9d0f600 100644 --- a/Integraions/ManagedCode.Storage.Server/Extensions/File/BrowserFileExtensions.cs +++ b/Integraions/ManagedCode.Storage.Server/Extensions/File/BrowserFileExtensions.cs @@ -10,10 +10,16 @@ public static class BrowserFileExtensions public static async Task ToLocalFileAsync(this IBrowserFile formFile, CancellationToken cancellationToken = default) { var localFile = LocalFile.FromRandomNameWithExtension(formFile.Name); - await using (var stream = formFile.OpenReadStream(cancellationToken: cancellationToken)) + try { + await using var stream = formFile.OpenReadStream(maxAllowedSize: formFile.Size, cancellationToken: cancellationToken); await localFile.CopyFromStreamAsync(stream, cancellationToken); + return localFile; + } + catch + { + await localFile.DisposeAsync(); + throw; } - return localFile; } } diff --git a/Integraions/ManagedCode.Storage.Server/Extensions/Storage/StorageBrowserFileExtensions.cs b/Integraions/ManagedCode.Storage.Server/Extensions/Storage/StorageBrowserFileExtensions.cs index b7e3eb5..5592117 100644 --- a/Integraions/ManagedCode.Storage.Server/Extensions/Storage/StorageBrowserFileExtensions.cs +++ b/Integraions/ManagedCode.Storage.Server/Extensions/Storage/StorageBrowserFileExtensions.cs @@ -21,11 +21,11 @@ public static async Task> UploadToStorageAsync(this IStorag if (formFile.Size > threshold) { - var localFile = await formFile.ToLocalFileAsync(cancellationToken); + await using var localFile = await formFile.ToLocalFileAsync(cancellationToken); return await storage.UploadAsync(localFile.FileInfo, options, cancellationToken); } - await using (var stream = formFile.OpenReadStream(cancellationToken: cancellationToken)) + await using (var stream = formFile.OpenReadStream(maxAllowedSize: threshold, cancellationToken: cancellationToken)) { return await storage.UploadAsync(stream, options, cancellationToken); } @@ -41,11 +41,11 @@ public static async Task> UploadToStorageAsync(this IStorag if (formFile.Size > threshold) { - var localFile = await formFile.ToLocalFileAsync(cancellationToken); + await using var localFile = await formFile.ToLocalFileAsync(cancellationToken); return await storage.UploadAsync(localFile.FileInfo, newOptions, cancellationToken); } - await using (var stream = formFile.OpenReadStream(cancellationToken: cancellationToken)) + await using (var stream = formFile.OpenReadStream(maxAllowedSize: threshold, cancellationToken: cancellationToken)) { return await storage.UploadAsync(stream, newOptions, cancellationToken); } diff --git a/ManagedCode.Storage.Core/AGENTS.md b/ManagedCode.Storage.Core/AGENTS.md index d2b451b..00fb2cd 100644 --- a/ManagedCode.Storage.Core/AGENTS.md +++ b/ManagedCode.Storage.Core/AGENTS.md @@ -53,3 +53,7 @@ Parent: `../AGENTS.md` - Preserve keyed and default storage resolution semantics because VFS, server integrations, and Orleans depend on them. - Shared low-level storage contracts and value types belong in `ManagedCode.Storage.Core.Primitives`; do not introduce a generic `Objects` namespace. + +## Scoped Coverage Repair Maintainability Exception + +- `LocalFile` and `BaseStorage` already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/ManagedCode.Storage.Core/BaseStorage.cs b/ManagedCode.Storage.Core/BaseStorage.cs index e4e1fac..f0eb2fe 100644 --- a/ManagedCode.Storage.Core/BaseStorage.cs +++ b/ManagedCode.Storage.Core/BaseStorage.cs @@ -27,9 +27,11 @@ protected BaseStorage(TOptions storageOptions) public async Task CreateContainerAsync(CancellationToken cancellationToken = default) { + var acquired = false; try { await _semaphoreSlim.WaitAsync(cancellationToken); + acquired = true; cancellationToken.ThrowIfCancellationRequested(); var result = await CreateContainerInternalAsync(cancellationToken); @@ -43,7 +45,7 @@ public async Task CreateContainerAsync(CancellationToken cancellationTok } finally { - _semaphoreSlim.Release(); + if (acquired) _semaphoreSlim.Release(); } } diff --git a/ManagedCode.Storage.Core/Models/LocalFile.cs b/ManagedCode.Storage.Core/Models/LocalFile.cs index 3e4b00f..a043304 100644 --- a/ManagedCode.Storage.Core/Models/LocalFile.cs +++ b/ManagedCode.Storage.Core/Models/LocalFile.cs @@ -19,7 +19,7 @@ public LocalFile(string? path = null, bool keepAlive = false) KeepAlive = keepAlive; var resolvedPath = path; - if (string.IsNullOrEmpty(Path.GetExtension(path))) + if (!File.Exists(path) && string.IsNullOrEmpty(Path.GetExtension(path))) { var directoryPath = Path.GetDirectoryName(path); var name = Path.GetFileName(path); diff --git a/ManagedCode.Storage.Core/StringStream.cs b/ManagedCode.Storage.Core/StringStream.cs index 9a76949..6a3161b 100644 --- a/ManagedCode.Storage.Core/StringStream.cs +++ b/ManagedCode.Storage.Core/StringStream.cs @@ -30,7 +30,8 @@ public override long Seek(long offset, SeekOrigin origin) public override int Read(byte[] buffer, int offset, int count) { - var length = Math.Min(count, Length - Position); + ValidateBufferArguments(buffer, offset, count); + var length = Math.Min(count, Math.Max(0, Length - Position)); for (var i = 0; i < length; i++) buffer[offset + i] = this[(int)Position++]; diff --git a/ManagedCode.Storage.VirtualFileSystem/AGENTS.md b/ManagedCode.Storage.VirtualFileSystem/AGENTS.md index 1d2803f..d624d7d 100644 --- a/ManagedCode.Storage.VirtualFileSystem/AGENTS.md +++ b/ManagedCode.Storage.VirtualFileSystem/AGENTS.md @@ -51,3 +51,7 @@ Parent: `../AGENTS.md` - Keep VFS layered on `IStorage` and `IStorageFactory`; do not add direct dependencies on provider internals. - Preserve the separation between metadata management and byte-stream operations. - Any keyed-storage behavior here must stay compatible with the StorageFactory conventions used across the repo. + +## Scoped Coverage Repair Maintainability Exception + +- `VirtualDirectory`, `VirtualFileSystem` and the existing `VirtualFile` type already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs index 3ba8324..77dfae2 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs @@ -348,6 +348,7 @@ public async Task GetStatsAsync( { if (entry.Type == VfsEntryType.File && entry is IVirtualFile file) { + await file.RefreshAsync(cancellationToken); fileCount++; totalSize += file.Size; diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs new file mode 100644 index 0000000..72effeb --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs @@ -0,0 +1,54 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace ManagedCode.Storage.VirtualFileSystem.Implementations; + +public partial class VirtualFile +{ + /// + public async ValueTask ReadRangeAsync(long offset, int count, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + await using var stream = await OpenReadAsync(cancellationToken: cancellationToken); + if (stream.CanSeek) + { + if (offset >= stream.Length) + return Array.Empty(); + stream.Seek(offset, SeekOrigin.Begin); + } + else if (!await SkipForwardAsync(stream, offset, cancellationToken)) + return Array.Empty(); + + var buffer = new byte[count]; + var bytesRead = await stream.ReadAtLeastAsync(buffer, count, throwOnEndOfStream: false, cancellationToken); + if (bytesRead < count) + Array.Resize(ref buffer, bytesRead); + return buffer; + } + + private static async Task SkipForwardAsync(Stream stream, long count, CancellationToken cancellationToken) + { + if (count == 0) + return true; + var buffer = ArrayPool.Shared.Rent(81920); + try + { + while (count > 0) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(count, buffer.Length)), cancellationToken); + if (read == 0) + return false; + count -= read; + } + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs index 0a39165..5ae9aaf 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs @@ -19,7 +19,7 @@ namespace ManagedCode.Storage.VirtualFileSystem.Implementations; /// /// Implementation of a virtual file /// -public class VirtualFile : IVirtualFile +public partial class VirtualFile : IVirtualFile { private readonly IVirtualFileSystem _vfs; private readonly IMetadataManager _metadataManager; @@ -190,27 +190,6 @@ public async Task OpenWriteAsync( } /// - public async ValueTask ReadRangeAsync( - long offset, - int count, - CancellationToken cancellationToken = default) - { - _logger.LogDebug("Reading range: {Path}, offset: {Offset}, count: {Count}", _path, offset, count); - - await using var stream = await OpenReadAsync( - new StreamOptions { RangeStart = offset, RangeEnd = offset + count - 1 }, - cancellationToken); - - var buffer = new byte[count]; - var bytesRead = await stream.ReadAsync(buffer, 0, count, cancellationToken); - - if (bytesRead < count) - { - Array.Resize(ref buffer, bytesRead); - } - - return buffer; - } /// public async Task ReadAllBytesAsync(CancellationToken cancellationToken = default) diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs index 9046e26..c09c6d8 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs @@ -199,6 +199,23 @@ public async ValueTask DirectoryExistsAsync(VfsPath path, CancellationToke } } + private void InvalidateDeletedFileCaches(string blobKey) + { + if (!_options.EnableCache) return; + + var path = new VfsPath(blobKey); + _cache.Remove($"file_exists:{ContainerName}:{path}"); + _cache.Remove($"file_metadata:{ContainerName}:{path}"); + _cache.Remove($"file_custom_metadata:{ContainerName}:{path}"); + var parent = path.GetParent(); + while (!parent.IsRoot) + { + _cache.Remove($"dir_exists:{ContainerName}:{parent}"); + parent = parent.GetParent(); + } + _cache.Remove($"dir_exists:{ContainerName}:{parent}"); + } + /// public async Task DeleteDirectoryAsync( VfsPath path, @@ -244,6 +261,7 @@ public async Task DeleteDirectoryAsync( if (deleteResult.IsSuccess && deleteResult.Value) { result.FilesDeleted++; + InvalidateDeletedFileCaches(fileName); } else { diff --git a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs index 87b273a..ad156eb 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs @@ -256,7 +256,9 @@ protected Dictionary BuildMetadataDictionary( var customPrefix = $"{MetadataPrefix}{VfsCustomPrefix}"; foreach (var kvp in storageMetadata) { - if (kvp.Key.StartsWith(customPrefix, StringComparison.Ordinal)) + if (kvp.Key.StartsWith(customPrefix, StringComparison.Ordinal) && + kvp.Key != versionKey && kvp.Key != createdKey && + kvp.Key != modifiedKey && kvp.Key != attributesKey) { var customKey = kvp.Key[customPrefix.Length..]; metadata.CustomMetadata[customKey] = kvp.Value; diff --git a/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs index 1b4f196..1d9bc57 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs @@ -95,6 +95,7 @@ protected override void Dispose(bool disposing) catch (Exception ex) { _logger.LogError(ex, "Error uploading data during stream dispose: {BlobKey}", _blobKey); + throw; } finally { diff --git a/README.md b/README.md index 7d0b051..8fa11f9 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,11 @@ [![Release](https://github.com/managedcode/Storage/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/managedcode/Storage/actions/workflows/release.yml) [![CodeQL](https://github.com/managedcode/Storage/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/managedcode/Storage/actions/workflows/codeql-analysis.yml) [![Codecov](https://codecov.io/gh/managedcode/Storage/graph/badge.svg?token=OMKP91GPVD)](https://codecov.io/gh/managedcode/Storage) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=managedcode_Storage&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=managedcode_Storage) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=managedcode_Storage&metric=coverage)](https://sonarcloud.io/summary/new_code?id=managedcode_Storage) [![MCAF](https://img.shields.io/badge/MCAF-enabled-785D8F)](https://mcaf.managed-code.com/) [![.NET](https://img.shields.io/badge/.NET-10.0-512BD4?logo=dotnet)](https://dotnet.microsoft.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Core.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Core) +[![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Core.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Core) +[![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Core.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Core) Cross-provider blob storage toolkit for .NET and ASP.NET streaming scenarios. @@ -116,30 +115,32 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa ## Packages +Package links open the latest stable release on NuGet; badges update automatically. + ### Core & Utilities -| Package | Latest | Description | -| --- | --- | --- | -| [ManagedCode.Storage.Core](https://www.nuget.org/packages/ManagedCode.Storage.Core) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Core.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Core) | Core abstractions, option models, CRC32/MIME helpers, and DI extensions. | -| [ManagedCode.Storage.VirtualFileSystem](https://www.nuget.org/packages/ManagedCode.Storage.VirtualFileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.VirtualFileSystem.svg)](https://www.nuget.org/packages/ManagedCode.Storage.VirtualFileSystem) | Virtual file system overlay on top of `IStorage` (file/directory API + caching; not a provider). | -| [ManagedCode.Storage.TestFakes](https://www.nuget.org/packages/ManagedCode.Storage.TestFakes) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.TestFakes.svg)](https://www.nuget.org/packages/ManagedCode.Storage.TestFakes) | Provider doubles for unit/integration tests without hitting cloud services. | +| Package | Latest version | Downloads | Description | +| --- | --- | --- | --- | +| [ManagedCode.Storage.Core](https://www.nuget.org/packages/ManagedCode.Storage.Core) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Core.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Core) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Core.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Core) | Core abstractions, option models, CRC32/MIME helpers, and DI extensions. | +| [ManagedCode.Storage.VirtualFileSystem](https://www.nuget.org/packages/ManagedCode.Storage.VirtualFileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.VirtualFileSystem.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.VirtualFileSystem) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.VirtualFileSystem.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.VirtualFileSystem) | Virtual file system overlay on top of `IStorage` (file/directory API + caching; not a provider). | +| [ManagedCode.Storage.TestFakes](https://www.nuget.org/packages/ManagedCode.Storage.TestFakes) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.TestFakes.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.TestFakes) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.TestFakes.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.TestFakes) | Provider doubles for unit/integration tests without hitting cloud services. | ### Providers -| Package | Latest | Description | -| --- | --- | --- | -| [ManagedCode.Storage.Azure](https://www.nuget.org/packages/ManagedCode.Storage.Azure) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Azure.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Azure) | Azure Blob Storage implementation with metadata, streaming, and legal hold support. | -| [ManagedCode.Storage.Azure.DataLake](https://www.nuget.org/packages/ManagedCode.Storage.Azure.DataLake) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Azure.DataLake.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Azure.DataLake) | Azure Data Lake Gen2 provider on top of the unified abstraction. | -| [ManagedCode.Storage.Aws](https://www.nuget.org/packages/ManagedCode.Storage.Aws) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Aws.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Aws) | Amazon S3 provider with Object Lock and legal hold operations. | -| [ManagedCode.Storage.Gcp](https://www.nuget.org/packages/ManagedCode.Storage.Gcp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Gcp.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Gcp) | Google Cloud Storage integration built on official SDKs. | -| [ManagedCode.Storage.Browser](https://www.nuget.org/packages/ManagedCode.Storage.Browser) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Browser.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Browser) | Browser storage provider with Blazor DI helpers, MVC/static-asset helpers, IndexedDB metadata, and OPFS-backed payload streaming. | -| [ManagedCode.Storage.Cartograph](https://www.nuget.org/packages/ManagedCode.Storage.Cartograph) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Cartograph.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Cartograph) | Read-only provider for files inside immutable Cartograph artifacts, with mapped streaming, catalog metadata, and directory listings. | -| [ManagedCode.Storage.FileSystem](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.FileSystem.svg)](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | Local file system implementation for hybrid or on-premises workloads. | -| [ManagedCode.Storage.Sftp](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Sftp.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | SFTP provider powered by SSH.NET for regulated and air-gapped environments. | -| [ManagedCode.Storage.OneDrive](https://www.nuget.org/packages/ManagedCode.Storage.OneDrive) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.OneDrive.svg)](https://www.nuget.org/packages/ManagedCode.Storage.OneDrive) | OneDrive provider built on Microsoft Graph. | -| [ManagedCode.Storage.GoogleDrive](https://www.nuget.org/packages/ManagedCode.Storage.GoogleDrive) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.GoogleDrive.svg)](https://www.nuget.org/packages/ManagedCode.Storage.GoogleDrive) | Google Drive provider built on the Google Drive API. | -| [ManagedCode.Storage.Dropbox](https://www.nuget.org/packages/ManagedCode.Storage.Dropbox) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Dropbox.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Dropbox) | Dropbox provider built on the Dropbox API. | -| [ManagedCode.Storage.CloudKit](https://www.nuget.org/packages/ManagedCode.Storage.CloudKit) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.CloudKit.svg)](https://www.nuget.org/packages/ManagedCode.Storage.CloudKit) | CloudKit (iCloud app data) provider built on CloudKit Web Services. | +| Package | Latest version | Downloads | Description | +| --- | --- | --- | --- | +| [ManagedCode.Storage.Azure](https://www.nuget.org/packages/ManagedCode.Storage.Azure) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Azure.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Azure) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Azure.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Azure) | Azure Blob Storage implementation with metadata, streaming, and legal hold support. | +| [ManagedCode.Storage.Azure.DataLake](https://www.nuget.org/packages/ManagedCode.Storage.Azure.DataLake) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Azure.DataLake.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Azure.DataLake) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Azure.DataLake.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Azure.DataLake) | Azure Data Lake Gen2 provider on top of the unified abstraction. | +| [ManagedCode.Storage.Aws](https://www.nuget.org/packages/ManagedCode.Storage.Aws) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Aws.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Aws) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Aws.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Aws) | Amazon S3 provider with Object Lock and legal hold operations. | +| [ManagedCode.Storage.Gcp](https://www.nuget.org/packages/ManagedCode.Storage.Gcp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Gcp.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Gcp) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Gcp.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Gcp) | Google Cloud Storage integration built on official SDKs. | +| [ManagedCode.Storage.Browser](https://www.nuget.org/packages/ManagedCode.Storage.Browser) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Browser.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Browser) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Browser.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Browser) | Browser storage provider with Blazor DI helpers, MVC/static-asset helpers, IndexedDB metadata, and OPFS-backed payload streaming. | +| [ManagedCode.Storage.Cartograph](https://www.nuget.org/packages/ManagedCode.Storage.Cartograph) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Cartograph.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Cartograph) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Cartograph.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Cartograph) | Read-only provider for files inside immutable Cartograph artifacts, with mapped streaming, catalog metadata, and directory listings. | +| [ManagedCode.Storage.FileSystem](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.FileSystem.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.FileSystem.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | Local file system implementation for hybrid or on-premises workloads. | +| [ManagedCode.Storage.Sftp](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Sftp.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Sftp.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | SFTP provider powered by SSH.NET for regulated and air-gapped environments. | +| [ManagedCode.Storage.OneDrive](https://www.nuget.org/packages/ManagedCode.Storage.OneDrive) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.OneDrive.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.OneDrive) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.OneDrive.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.OneDrive) | OneDrive provider built on Microsoft Graph. | +| [ManagedCode.Storage.GoogleDrive](https://www.nuget.org/packages/ManagedCode.Storage.GoogleDrive) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.GoogleDrive.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.GoogleDrive) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.GoogleDrive.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.GoogleDrive) | Google Drive provider built on the Google Drive API. | +| [ManagedCode.Storage.Dropbox](https://www.nuget.org/packages/ManagedCode.Storage.Dropbox) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Dropbox.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Dropbox) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Dropbox.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Dropbox) | Dropbox provider built on the Dropbox API. | +| [ManagedCode.Storage.CloudKit](https://www.nuget.org/packages/ManagedCode.Storage.CloudKit) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.CloudKit.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.CloudKit) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.CloudKit.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.CloudKit) | CloudKit (iCloud app data) provider built on CloudKit Web Services. | ### Cartograph artifacts @@ -417,12 +418,12 @@ var tenantStorage = app.Services.GetRequiredKeyedService("tenant-a"); ### Integrations -| Package | Latest | Description | -| --- | --- | --- | -| [ManagedCode.Storage.Orleans](https://www.nuget.org/packages/ManagedCode.Storage.Orleans) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Orleans.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Orleans) | Orleans grain persistence provider that stores grain state through any ManagedCode `IStorage` registration. | -| [ManagedCode.Storage.Server](https://www.nuget.org/packages/ManagedCode.Storage.Server) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Server.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Server) | ASP.NET controllers, chunk orchestration services, and the SignalR storage hub. | -| [ManagedCode.Storage.Client](https://www.nuget.org/packages/ManagedCode.Storage.Client) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Client.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Client) | .NET client SDK for uploads, downloads, metadata, and SignalR negotiations. | -| [ManagedCode.Storage.Client.SignalR](https://www.nuget.org/packages/ManagedCode.Storage.Client.SignalR) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Client.SignalR.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Client.SignalR) | SignalR streaming client for browsers and native applications. | +| Package | Latest version | Downloads | Description | +| --- | --- | --- | --- | +| [ManagedCode.Storage.Orleans](https://www.nuget.org/packages/ManagedCode.Storage.Orleans) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Orleans.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Orleans) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Orleans.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Orleans) | Orleans grain persistence provider that stores grain state through any ManagedCode `IStorage` registration. | +| [ManagedCode.Storage.Server](https://www.nuget.org/packages/ManagedCode.Storage.Server) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Server.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Server) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Server.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Server) | ASP.NET controllers, chunk orchestration services, and the SignalR storage hub. | +| [ManagedCode.Storage.Client](https://www.nuget.org/packages/ManagedCode.Storage.Client) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Client.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Client) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Client.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Client) | .NET client SDK for uploads, downloads, metadata, and SignalR negotiations. | +| [ManagedCode.Storage.Client.SignalR](https://www.nuget.org/packages/ManagedCode.Storage.Client.SignalR) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Client.SignalR.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Client.SignalR) | [![Downloads](https://img.shields.io/nuget/dt/ManagedCode.Storage.Client.SignalR.svg?style=flat-square&logo=nuget)](https://www.nuget.org/packages/ManagedCode.Storage.Client.SignalR) | SignalR streaming client for browsers and native applications. | ## Architecture diff --git a/Storages/ManagedCode.Storage.Aws/AGENTS.md b/Storages/ManagedCode.Storage.Aws/AGENTS.md index 246ffb3..09d095f 100644 --- a/Storages/ManagedCode.Storage.Aws/AGENTS.md +++ b/Storages/ManagedCode.Storage.Aws/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Keep S3 bucket and object-key semantics explicit and avoid leaking AWS SDK details outside the provider boundary. + +## Scoped Coverage Repair Maintainability Exception + +- `AWSStorage` and `BlobStream` already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects; the stream validation additions in `BlobStream` are included in this exception. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Storages/ManagedCode.Storage.Aws/AWSStorage.cs b/Storages/ManagedCode.Storage.Aws/AWSStorage.cs index d4ea057..a6fb528 100644 --- a/Storages/ManagedCode.Storage.Aws/AWSStorage.cs +++ b/Storages/ManagedCode.Storage.Aws/AWSStorage.cs @@ -238,6 +238,9 @@ protected override async Task> DeleteInternalAsync(DeleteOptions op var isExist = await ExistsAsync(ExistOptions.FromBaseOptions(options), cancellationToken); + if (isExist.IsFailed) + return isExist; + if (!isExist.Value) return Result.Succeed(false); diff --git a/Storages/ManagedCode.Storage.Aws/BlobStream.cs b/Storages/ManagedCode.Storage.Aws/BlobStream.cs index 0931952..c1829e7 100644 --- a/Storages/ManagedCode.Storage.Aws/BlobStream.cs +++ b/Storages/ManagedCode.Storage.Aws/BlobStream.cs @@ -41,13 +41,13 @@ public BlobStream(IAmazonS3 s3, string bucket, string key, long partLength = DEF public override bool CanRead => false; public override bool CanSeek => false; - public override bool CanWrite => true; + public override bool CanWrite => !_disposed; public override long Length => _metadata.Length = Math.Max(_metadata.Length, _metadata.Position); public override long Position { get => _metadata.Position; - set => throw new NotImplementedException(); + set => throw new NotSupportedException(); } protected override void Dispose(bool disposing) @@ -60,8 +60,20 @@ protected override void Dispose(bool disposing) if (disposing) { - Flush(true); - CompleteUpload(); + if (_metadata.Position == 0 && _metadata.UploadId is null) + { + _s3.PutObjectAsync(new PutObjectRequest + { + BucketName = _metadata.BucketName, + Key = _metadata.Key, + InputStream = Stream.Null + }).GetAwaiter().GetResult(); + } + else + { + Flush(true); + CompleteUpload(); + } } _disposed = true; @@ -70,12 +82,12 @@ protected override void Dispose(bool disposing) public override int Read(byte[] buffer, int offset, int count) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public override long Seek(long offset, SeekOrigin origin) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public override void SetLength(long value) @@ -95,6 +107,7 @@ private void StartNewPart() public override void Flush() { + ObjectDisposedException.ThrowIf(_disposed, this); Flush(false); } @@ -160,6 +173,13 @@ private void CompleteUpload() public override void Write(byte[] buffer, int offset, int count) { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length); + if (count > buffer.Length - offset) + throw new ArgumentException("The requested range exceeds the buffer length.", nameof(count)); if (count == 0) return; // write as much of the buffer as will fit to the current part, and if needed diff --git a/Storages/ManagedCode.Storage.Azure.DataLake/AGENTS.md b/Storages/ManagedCode.Storage.Azure.DataLake/AGENTS.md index 8927f4a..0300e00 100644 --- a/Storages/ManagedCode.Storage.Azure.DataLake/AGENTS.md +++ b/Storages/ManagedCode.Storage.Azure.DataLake/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Preserve Data Lake filesystem and path hierarchy semantics; do not flatten them into blob-style assumptions. + +## Scoped Coverage Repair Maintainability Exception + +- `AzureDataLakeStorage` already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Storages/ManagedCode.Storage.Azure.DataLake/AzureDataLakeStorage.cs b/Storages/ManagedCode.Storage.Azure.DataLake/AzureDataLakeStorage.cs index 68899d7..404f016 100644 --- a/Storages/ManagedCode.Storage.Azure.DataLake/AzureDataLakeStorage.cs +++ b/Storages/ManagedCode.Storage.Azure.DataLake/AzureDataLakeStorage.cs @@ -17,20 +17,18 @@ namespace ManagedCode.Storage.Azure.DataLake; public class AzureDataLakeStorage : BaseStorage, IAzureDataLakeStorage { - private readonly DataLakeServiceClient _dataLakeServiceClient; private readonly ILogger? _logger; public AzureDataLakeStorage(AzureDataLakeStorageOptions options, ILogger? logger = null) : base(options) { _logger = logger; - _dataLakeServiceClient = new DataLakeServiceClient(options.ConnectionString); } public override async Task RemoveContainerAsync(CancellationToken cancellationToken = default) { try { - await _dataLakeServiceClient.DeleteFileSystemAsync(StorageOptions.FileSystem, cancellationToken: cancellationToken); + await StorageClient.DeleteAsync(cancellationToken: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); return Result.Succeed(); } @@ -139,14 +137,14 @@ public override async Task> GetStreamAsync(string fileName, Cance protected override DataLakeFileSystemClient CreateStorageClient() { - return _dataLakeServiceClient.GetFileSystemClient(StorageOptions.FileSystem); + return new DataLakeServiceClient(StorageOptions.ConnectionString).GetFileSystemClient(StorageOptions.FileSystem); } protected override async Task CreateContainerInternalAsync(CancellationToken cancellationToken = default) { try { - await _dataLakeServiceClient.CreateFileSystemAsync(StorageOptions.FileSystem, StorageOptions.PublicAccessType, cancellationToken); + await StorageClient.CreateAsync(StorageOptions.PublicAccessType, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); return Result.Succeed(); } diff --git a/Storages/ManagedCode.Storage.Azure/AGENTS.md b/Storages/ManagedCode.Storage.Azure/AGENTS.md index 59fb649..8c5244e 100644 --- a/Storages/ManagedCode.Storage.Azure/AGENTS.md +++ b/Storages/ManagedCode.Storage.Azure/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Keep Azure container and blob semantics explicit and preserve incremental blob streaming behavior. + +## Scoped Coverage Repair Maintainability Exception + +- the existing AzureStorage type already exceeds the inherited size limits. This repair keeps public signatures stable and changes only defects captured by real-runtime regressions. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider semantics and be reviewed separately. diff --git a/Storages/ManagedCode.Storage.Azure/AzureStorage.cs b/Storages/ManagedCode.Storage.Azure/AzureStorage.cs index 171a1fd..627d95f 100644 --- a/Storages/ManagedCode.Storage.Azure/AzureStorage.cs +++ b/Storages/ManagedCode.Storage.Azure/AzureStorage.cs @@ -109,7 +109,7 @@ public async Task> OpenWriteStreamAsync(string fileName, Cancella { await EnsureContainerExist(cancellationToken); var blobClient = StorageClient.GetBlobClient(fileName); - var stream = await blobClient.OpenWriteAsync(false, cancellationToken: cancellationToken); + var stream = await blobClient.OpenWriteAsync(overwrite: true, cancellationToken: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); return Result.Succeed(stream); } @@ -141,10 +141,31 @@ public async Task SetStorageOptions(IStorageOptions options, Cancellatio public async Task SetStorageOptions(Action options, CancellationToken cancellationToken = default) { - var type = options.GetType() - .GetGenericArguments()[0]; - - var clonedOptions = JsonSerializer.Deserialize(JsonSerializer.Serialize(StorageOptions), type) as IAzureStorageOptions; + IAzureStorageOptions? clonedOptions = StorageOptions switch + { + AzureStorageOptions source => new AzureStorageOptions + { + ConnectionString = source.ConnectionString, + Container = source.Container, + PublicAccessType = source.PublicAccessType, + OriginalOptions = source.OriginalOptions, + UploadTransferOptions = source.UploadTransferOptions, + CreateContainerIfNotExists = source.CreateContainerIfNotExists + }, + AzureStorageCredentialsOptions source => new AzureStorageCredentialsOptions + { + ServiceUri = source.ServiceUri, + AccountName = source.AccountName, + ContainerName = source.ContainerName, + Credentials = source.Credentials, + Container = source.Container, + PublicAccessType = source.PublicAccessType, + OriginalOptions = source.OriginalOptions, + UploadTransferOptions = source.UploadTransferOptions, + CreateContainerIfNotExists = source.CreateContainerIfNotExists + }, + _ => null + }; if (clonedOptions is null) return Result.Fail(new InvalidOperationException($"Unable to clone {nameof(IAzureStorageOptions)}.")); diff --git a/Storages/ManagedCode.Storage.FileSystem/AGENTS.md b/Storages/ManagedCode.Storage.FileSystem/AGENTS.md index 00628d7..cc3e0a7 100644 --- a/Storages/ManagedCode.Storage.FileSystem/AGENTS.md +++ b/Storages/ManagedCode.Storage.FileSystem/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Keep filesystem path handling safe and cross-platform; absolute-path assumptions and silent path escaping are regressions. + +## Scoped Coverage Repair Maintainability Exception + +- the existing FileSystemStorage type already exceeds the inherited size limits. This repair keeps public signatures stable and changes only defects captured by real-runtime regressions. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider semantics and be reviewed separately. diff --git a/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs b/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs index eaebdab..927c992 100644 --- a/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs +++ b/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs @@ -329,13 +329,7 @@ protected override async Task SetLegalHoldInternalAsync(bool hasLegalHol if (hasLegalHold && !_lockedFiles.ContainsKey(filePath)) { - var file = await DownloadAsync(filePath, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - - if (file.IsFailed) - return Result.Fail(file.Problem); - - var fileStream = File.OpenRead(file.Value!.FilePath); + var fileStream = File.OpenRead(filePath); if (!OperatingSystem.IsMacOS()) fileStream.Lock(0, fileStream.Length); diff --git a/Storages/ManagedCode.Storage.Google/AGENTS.md b/Storages/ManagedCode.Storage.Google/AGENTS.md index 6eeb9c7..45e508b 100644 --- a/Storages/ManagedCode.Storage.Google/AGENTS.md +++ b/Storages/ManagedCode.Storage.Google/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Preserve GCS bucket and object semantics plus credential-driven client creation without leaking Google SDK details across boundaries. + +## Scoped Coverage Repair Maintainability Exception + +- the existing GCPStorage type already exceeds the inherited size limits. This repair keeps public signatures stable and changes only defects captured by real-runtime regressions. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider semantics and be reviewed separately. diff --git a/Storages/ManagedCode.Storage.Google/GCPStorage.cs b/Storages/ManagedCode.Storage.Google/GCPStorage.cs index 63cf401..2ac23de 100644 --- a/Storages/ManagedCode.Storage.Google/GCPStorage.cs +++ b/Storages/ManagedCode.Storage.Google/GCPStorage.cs @@ -343,9 +343,12 @@ protected override async Task SetLegalHoldInternalAsync(bool hasLegalHol cancellationToken.ThrowIfCancellationRequested(); storageObject.TemporaryHold = hasLegalHold; - await StorageClient.UpdateObjectAsync(storageObject, cancellationToken: cancellationToken); + var updatedObject = await StorageClient.UpdateObjectAsync(storageObject, cancellationToken: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); + if ((updatedObject.TemporaryHold ?? false) != hasLegalHold) + return Result.Fail("Storage service did not apply the requested temporary hold."); + return Result.Succeed(); } catch (Exception ex) diff --git a/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorageProvider.cs b/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorageProvider.cs index 45734a4..c152182 100644 --- a/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorageProvider.cs +++ b/Storages/ManagedCode.Storage.GoogleDrive/GoogleDriveStorageProvider.cs @@ -32,6 +32,7 @@ public IStorageOptions GetDefaultOptions() RootFolderId = defaultOptions.RootFolderId, DriveService = defaultOptions.DriveService, Client = defaultOptions.Client, + SupportsAllDrives = defaultOptions.SupportsAllDrives, CreateContainerIfNotExists = defaultOptions.CreateContainerIfNotExists }; } diff --git a/Storages/ManagedCode.Storage.Sftp/AGENTS.md b/Storages/ManagedCode.Storage.Sftp/AGENTS.md index 9ee35f5..7d96233 100644 --- a/Storages/ManagedCode.Storage.Sftp/AGENTS.md +++ b/Storages/ManagedCode.Storage.Sftp/AGENTS.md @@ -50,3 +50,7 @@ Parent: `../../AGENTS.md` - Keep default and keyed DI registrations aligned with StorageFactory conventions and the provider-specific interface exposure. - Do not leak vendor SDK types outside provider-specific options, interfaces, or client wrappers. - Keep SSH connection and remote-path handling inside the provider and preserve stream-oriented uploads and downloads. + +## Scoped Coverage Repair Maintainability Exception + +- `SftpStorage` already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Storages/ManagedCode.Storage.Sftp/SftpStorage.cs b/Storages/ManagedCode.Storage.Sftp/SftpStorage.cs index 8049ec2..28c102a 100644 --- a/Storages/ManagedCode.Storage.Sftp/SftpStorage.cs +++ b/Storages/ManagedCode.Storage.Sftp/SftpStorage.cs @@ -47,6 +47,7 @@ public override async Task RemoveContainerAsync(CancellationToken cancel else if (StorageClient.Exists(root)) { await DeleteDirectoryRecursiveAsync(root, cancellationToken); + StorageClient.DeleteDirectory(root); } IsContainerCreated = false; @@ -247,14 +248,7 @@ protected override async Task CreateContainerInternalAsync(CancellationT { try { - EnsureConnected(); - - var root = NormalizeRemotePath(StorageOptions.RemoteDirectory); - if (!StorageClient.Exists(root)) - { - StorageClient.CreateDirectory(root); - } - + CreateRootDirectory(cancellationToken); return Result.Succeed(); } catch (Exception ex) @@ -301,11 +295,7 @@ protected override async Task> UploadInternalAsync(Stream s stream.Position = 0; } - await Task.Run(() => - { - cancellationToken.ThrowIfCancellationRequested(); - StorageClient.UploadFile(stream, remotePath, true); - }, cancellationToken); + await StorageClient.UploadFileAsync(stream, remotePath, cancellationToken); var metadataOptions = MetadataOptions.FromBaseOptions(options); return await GetBlobMetadataInternalAsync(metadataOptions, cancellationToken); @@ -451,6 +441,21 @@ protected override Task> HasLegalHoldInternalAsync(LegalHoldOptions private string CurrentRoot => NormalizeRemotePath(StorageOptions.RemoteDirectory, allowEmpty: false); + private void CreateRootDirectory(CancellationToken cancellationToken) + { + if (!StorageClient.IsConnected) StorageClient.Connect(); + var root = NormalizeRemotePath(StorageOptions.RemoteDirectory); + var path = "/"; + foreach (var segment in root.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + cancellationToken.ThrowIfCancellationRequested(); + path = path == "/" ? path + segment : path + "/" + segment; + if (!StorageClient.Exists(path)) StorageClient.CreateDirectory(path); + if (!StorageClient.GetAttributes(path).IsDirectory) + throw new InvalidOperationException($"SFTP container path is not a directory: {path}"); + } + } + private void EnsureConnected() { if (!StorageClient.IsConnected) @@ -466,7 +471,7 @@ private void EnsureConnected() { if (StorageOptions.CreateContainerIfNotExists && !StorageClient.Exists(CurrentRoot)) { - StorageClient.CreateDirectory(CurrentRoot); + CreateRootDirectory(CancellationToken.None); StorageClient.ChangeDirectory(CurrentRoot); } else @@ -594,7 +599,6 @@ private void DeleteDirectoryRecursive(string path, CancellationToken cancellatio if (entry.IsDirectory) { DeleteDirectoryRecursive(entry.FullName, cancellationToken); - StorageClient.DeleteDirectory(entry.FullName); } else { @@ -623,7 +627,6 @@ private Task DeleteDirectoryContentsAsync(string path, CancellationToken cancell if (entry.IsDirectory) { DeleteDirectoryRecursive(entry.FullName, cancellationToken); - StorageClient.DeleteDirectory(entry.FullName); } else { diff --git a/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserCoveragePlayground.razor b/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserCoveragePlayground.razor new file mode 100644 index 0000000..31b87ce --- /dev/null +++ b/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserCoveragePlayground.razor @@ -0,0 +1,87 @@ +@page "/coverage-playground" +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@using System.IO +@using System.Threading +@using ManagedCode.Storage.Browser +@using ManagedCode.Storage.Browser.Extensions +@using ManagedCode.Storage.Browser.Options +@using ManagedCode.Storage.Core +@using ManagedCode.Storage.Core.Models +@using Microsoft.Extensions.DependencyInjection +@using Microsoft.JSInterop +@inject IJSRuntime JsRuntime + + +

@_status

+ +@code { + private string _status = "ready"; + + private async Task RunAsync() + { + try + { + for (var registration = 0; registration < 6; registration++) + await VerifyRegistrationAsync(registration); + _status = "all-contracts-passed"; + } + catch (Exception exception) { _status = exception.ToString(); } + } + + private async Task VerifyRegistrationAsync(int registration) + { + var options = new BrowserStorageOptions { DatabaseName = "browser-contract-" + Guid.NewGuid().ToString("N"), ContainerName = "contract", ChunkSizeBytes = 1024 }; + void Configure(BrowserStorageOptions target) + { target.DatabaseName = options.DatabaseName; target.ContainerName = options.ContainerName; target.ChunkSizeBytes = options.ChunkSizeBytes; } + var services = new ServiceCollection(); + services.AddSingleton(JsRuntime); + switch (registration) + { + case 0: services.AddBrowserStorage(options); break; + case 1: services.AddBrowserStorage(Configure); break; + case 2: services.AddBrowserStorageAsDefault(options); break; + case 3: services.AddBrowserStorageAsDefault(Configure); break; + case 4: services.AddBrowserStorage("browser", Configure); break; + case 5: services.AddBrowserStorageAsDefault("browser", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var storage = registration >= 4 ? scope.ServiceProvider.GetRequiredKeyedService("browser") : scope.ServiceProvider.GetRequiredService(); + Require((await storage.CreateContainerAsync()).IsSuccess, "create"); + var bytes = new byte[4107]; + Random.Shared.NextBytes(bytes); + Require((await storage.UploadAsync(bytes, new UploadOptions { FileName = "content.bin" })).IsSuccess, "upload"); + var downloaded = await storage.DownloadAsync("content.bin"); + Require(downloaded.IsSuccess, "download"); + await using (var local = downloaded.Value!) + Require((await local.ReadAllBytesAsync()).SequenceEqual(bytes), "download bytes"); + Require((await storage.SetLegalHoldAsync(true, "content.bin")).IsSuccess, "set hold"); + Require((await storage.HasLegalHoldAsync("content.bin")).Value, "hold persisted"); + Require((await storage.SetLegalHoldAsync(false, "content.bin")).IsSuccess, "clear hold"); + var opened = await storage.GetStreamAsync("content.bin"); + Require(opened.IsSuccess, "open"); + await using (var stream = opened.Value!) + { + Require(stream.Length == bytes.Length && stream.CanSeek && !stream.CanWrite, "capabilities"); + Require(stream.Seek(3, SeekOrigin.Begin) == 3, "seek begin"); + var buffer = new byte[9]; + Require(await stream.ReadAsync(buffer.AsMemory()) == 9 && buffer.SequenceEqual(bytes[3..12]), "range bytes"); + Require(stream.Seek(-3, SeekOrigin.End) == bytes.Length - 3, "seek end"); + Require(await stream.ReadAsync(buffer.AsMemory()) == 3 && buffer[..3].SequenceEqual(bytes[^3..]), "short read"); + Require(await stream.ReadAsync(buffer.AsMemory()) == 0, "eof"); + stream.Seek(0, SeekOrigin.Begin); + Require(await Task.Run(() => stream.Read(buffer, 1, 3)) == 3 && buffer[1..4].SequenceEqual(bytes[..3]), "sync read"); + } + var missing = await storage.DownloadAsync("missing"); + Require(missing.IsFailed, "missing download"); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + Require((await storage.UploadAsync(bytes, new UploadOptions { FileName = "canceled" }, canceled.Token)).IsFailed, "cancellation"); + Require((await storage.GetBlobMetadataAsync("missing")).IsFailed, "missing metadata"); + Require((await storage.RemoveContainerAsync()).IsSuccess, "remove"); + await ((BrowserStorage)storage).DisposeAsync(); + } + + private static void Require(bool condition, string operation) + { if (!condition) throw new InvalidOperationException("Browser contract failed: " + operation); } +} diff --git a/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserFileContracts.razor b/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserFileContracts.razor new file mode 100644 index 0000000..eeb56a7 --- /dev/null +++ b/Tests/ManagedCode.Storage.BrowserServerHost/Components/Pages/BrowserFileContracts.razor @@ -0,0 +1,67 @@ +@page "/browser-file-contracts" +@rendermode @(new Microsoft.AspNetCore.Components.Web.InteractiveServerRenderMode(prerender: false)) +@using System.Security.Cryptography +@using ManagedCode.Storage.Core +@using ManagedCode.Storage.Core.Models +@using ManagedCode.Storage.FileSystem +@using ManagedCode.Storage.FileSystem.Options +@using ManagedCode.Storage.Server.Extensions.Storage +@using ManagedCode.Storage.Server.Extensions.Controller +@using ManagedCode.Storage.Server.Controllers +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Http +@using Microsoft.AspNetCore.Mvc +@using Microsoft.Extensions.DependencyInjection + + +

@_status

+ +@code { + private string _status = "ready"; + + private async Task RunAsync(InputFileChangeEventArgs args) + { + var root = Path.Combine(Path.GetTempPath(), "storage-browser-file-contracts", Guid.NewGuid().ToString("N")); + var extension = Path.GetExtension(args.File.Name); + try + { + var file = args.File; + _status = "reading-source"; + StateHasChanged(); + await using var original = file.OpenReadStream(1024 * 1024); + var expected = await SHA256.HashDataAsync(original); + var storage = new FileSystemStorage(new FileSystemStorageOptions { BaseFolder = root, CreateContainerIfNotExists = true }); + for (var mode = 0; mode < 6; mode++) + { + var options = new StorageServerOptions { InMemoryUploadThresholdBytes = mode % 2 == 0 ? 64 : 1024 * 1024 }; + var name = $"contract-{mode}.bin"; + if (mode < 2) + (await storage.UploadToStorageAsync(file, new UploadOptions(name), serverOptions: options)).ThrowIfFail(); + else if (mode < 4) + (await storage.UploadToStorageAsync(file, upload => upload.FileName = name, serverOptions: options)).ThrowIfFail(); + else + { + await using var services = new ServiceCollection().AddSingleton(options).BuildServiceProvider(); + var controller = new BrowserUploadController { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { RequestServices = services } } }; + await controller.UploadFromBrowserFileAsync(storage, file, new UploadOptions(name)); + } + var result = await storage.DownloadAsync(name); + result.ThrowIfFail(); + await using var downloaded = result.Value!; + await using var read = downloaded.OpenReadStream(disposeOwner: false); + if (!(await SHA256.HashDataAsync(read)).AsSpan().SequenceEqual(expected)) + throw new InvalidOperationException($"Browser file mismatch in mode {mode}."); + if (Directory.GetFiles(Path.GetTempPath(), "*" + extension).Length != 0) + throw new InvalidOperationException($"Temporary browser upload was not disposed in mode {mode}."); + } + _status = Convert.ToHexString(expected); + } + catch (Exception exception) { _status = exception.ToString(); } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private sealed class BrowserUploadController : ControllerBase { } +} diff --git a/Tests/ManagedCode.Storage.BrowserServerHost/ManagedCode.Storage.BrowserServerHost.csproj b/Tests/ManagedCode.Storage.BrowserServerHost/ManagedCode.Storage.BrowserServerHost.csproj index 05bcb7a..28b884c 100644 --- a/Tests/ManagedCode.Storage.BrowserServerHost/ManagedCode.Storage.BrowserServerHost.csproj +++ b/Tests/ManagedCode.Storage.BrowserServerHost/ManagedCode.Storage.BrowserServerHost.csproj @@ -9,6 +9,8 @@ + + diff --git a/Tests/ManagedCode.Storage.BrowserServerHost/Program.cs b/Tests/ManagedCode.Storage.BrowserServerHost/Program.cs index e90d573..9592e6e 100644 --- a/Tests/ManagedCode.Storage.BrowserServerHost/Program.cs +++ b/Tests/ManagedCode.Storage.BrowserServerHost/Program.cs @@ -36,4 +36,14 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode(); +app.MapPost("/__test/shutdown", (HttpContext context, IHostApplicationLifetime lifetime) => +{ + context.Response.OnCompleted(() => + { + lifetime.StopApplication(); + return Task.CompletedTask; + }); + return Results.Ok(); +}); + app.Run(); diff --git a/Tests/ManagedCode.Storage.Tests/AGENTS.md b/Tests/ManagedCode.Storage.Tests/AGENTS.md index 817f859..c89870b 100644 --- a/Tests/ManagedCode.Storage.Tests/AGENTS.md +++ b/Tests/ManagedCode.Storage.Tests/AGENTS.md @@ -27,7 +27,7 @@ Parent: `../../AGENTS.md` - `build`: `dotnet build ManagedCode.Storage.Tests.csproj` - `test`: `dotnet test ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress"` - `browser-stress`: `dotnet test ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category=BrowserStress"` -- `coverage`: `dotnet test ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress" /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover` +- `coverage`: `dotnet test ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress" /p:CollectCoverage=true` - `format`: `dotnet format ../../ManagedCode.Storage.slnx` - Active test framework: `xUnit` - Runner model: `VSTest` @@ -56,3 +56,11 @@ Parent: `../../AGENTS.md` - Keep architecture dependency rules in `Architecture/` focused on durable package boundaries so they stay stable as implementation details move. - Do not weaken assertions or skip suites to get a green run; fix the real regression or document an explicit exception. - Browser stress tests are an explicit lane, not hidden debt: keep them automated via the dedicated `browser-stress` command or workflow instead of folding them into the fast default test path. + +## External Data Lake Protocol Test Exception + +- ADLS Gen2 tests may use the file-backed Kestrel protocol host in `Storages/DataLake/` because Azurite does not implement the DFS hierarchical-namespace protocol and no hosted test account is configured. Exercise the real Azure SDK over HTTP, assert stored bytes and failure results, and keep this exception limited to the external Azure service. This is SDK/provider contract evidence; hosted Azure authentication and service behavior require separate qualification. + +## Scoped Coverage Repair Maintainability Exception + +- existing provider and controller test bases affected by the framework migration already contain responsibilities above the inherited size limits. This repair keeps existing public contracts stable and limits implementation changes to directly tested defects. Do not expand unrelated behavior under this exception. Further decomposition must preserve provider/transport semantics and be reviewed as a separate change. diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseDownloadControllerTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseDownloadControllerTests.cs index 51081c8..24c7342 100644 --- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseDownloadControllerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseDownloadControllerTests.cs @@ -35,12 +35,12 @@ public async Task DownloadFile_WhenFileExists_SaveToTempStorage_ReturnSuccess() FileHelper.GenerateLocalFileWithData(localFile, 100); // Generate file with actual data var fileCRC = Crc32Helper.CalculateFileCrc(localFile.FilePath); // Calculate CRC from file path await using var uploadStream = localFile.FileStream; // Get stream once - var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName); + var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); uploadFileBlob.IsSuccess.ShouldBeTrue(); var uploadedMetadata = uploadFileBlob.Value ?? throw new InvalidOperationException("Upload did not return metadata"); // Act - var downloadedFileResult = await storageClient.DownloadFile(uploadedMetadata.FullName, _downloadEndpoint); + var downloadedFileResult = await storageClient.DownloadFile(uploadedMetadata.FullName, _downloadEndpoint, cancellationToken: TestContext.Current.CancellationToken); // Assert downloadedFileResult.IsSuccess @@ -61,12 +61,12 @@ public async Task DownloadFileAsBytes_WhenFileExists_ReturnSuccess() FileHelper.GenerateLocalFileWithData(localFile, 100); // Generate file with actual data var fileCRC = Crc32Helper.CalculateFileCrc(localFile.FilePath); // Calculate CRC from file path await using var uploadStream = localFile.FileStream; // Get stream once - var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName); + var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); uploadFileBlob.IsSuccess.ShouldBeTrue(); var uploadedMetadata = uploadFileBlob.Value ?? throw new InvalidOperationException("Upload did not return metadata"); // Act - var downloadedFileResult = await storageClient.DownloadFile(uploadedMetadata.FullName, _downloadBytesEndpoint); + var downloadedFileResult = await storageClient.DownloadFile(uploadedMetadata.FullName, _downloadBytesEndpoint, cancellationToken: TestContext.Current.CancellationToken); // Assert downloadedFileResult.IsSuccess.ShouldBeTrue(); @@ -83,7 +83,7 @@ public async Task DownloadFile_WhenFileDoNotExist_ReturnFail() // Act var downloadedFileResult = await storageClient.DownloadFile(Guid.NewGuid() - .ToString(), _downloadEndpoint); + .ToString(), _downloadEndpoint, cancellationToken: TestContext.Current.CancellationToken); // Assert downloadedFileResult.IsFailed diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseStreamControllerTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseStreamControllerTests.cs index 8d1d90f..d3b7d20 100644 --- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseStreamControllerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseStreamControllerTests.cs @@ -34,12 +34,12 @@ public async Task StreamFile_WhenFileExists_SaveToTempStorage_ReturnSuccess() FileHelper.GenerateLocalFileWithData(localFile, 100); // Generate file with actual data var fileCRC = Crc32Helper.CalculateFileCrc(localFile.FilePath); // Calculate CRC from file path await using var uploadStream = localFile.FileStream; // Get stream once - var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName); + var uploadFileBlob = await storageClient.UploadFile(uploadStream, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); uploadFileBlob.IsSuccess.ShouldBeTrue(); var uploadedMetadata = uploadFileBlob.Value ?? throw new InvalidOperationException("Upload did not return metadata"); // Act - var streamFileResult = await storageClient.GetFileStream(uploadedMetadata.FullName, _streamEndpoint); + var streamFileResult = await storageClient.GetFileStream(uploadedMetadata.FullName, _streamEndpoint, TestContext.Current.CancellationToken); // Assert streamFileResult.IsSuccess @@ -48,7 +48,7 @@ public async Task StreamFile_WhenFileExists_SaveToTempStorage_ReturnSuccess() await using var stream = streamedValue; await using var newLocalFile = await LocalFile.FromStreamAsync(stream, Environment.CurrentDirectory, Guid.NewGuid() - .ToString("N") + extension); + .ToString("N") + extension, TestContext.Current.CancellationToken); var streamedFileCRC = Crc32Helper.CalculateFileCrc(newLocalFile.FilePath); streamedFileCRC.ShouldBe(fileCRC); @@ -62,7 +62,7 @@ public async Task StreamFile_WhenFileDoNotExist_ReturnFail() // Act var streamFileResult = await storageClient.GetFileStream(Guid.NewGuid() - .ToString(), _streamEndpoint); + .ToString(), _streamEndpoint, TestContext.Current.CancellationToken); // Assert streamFileResult.IsFailed diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseUploadControllerTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseUploadControllerTests.cs index 0c17450..2129e3d 100644 --- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseUploadControllerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Abstracts/BaseUploadControllerTests.cs @@ -37,7 +37,7 @@ public async Task UploadFileFromStream_WhenFileValid_ReturnSuccess() FileHelper.GenerateLocalFile(localFile, 1); // Act - var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName); + var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -46,18 +46,27 @@ public async Task UploadFileFromStream_WhenFileValid_ReturnSuccess() .ShouldNotBeNull(); } - [Fact(Skip = "There is no forbidden logic")] + [Fact] public async Task UploadFileFromStream_WhenFileSizeIsForbidden_ReturnFail() { // Arrange - var storageClient = GetStorageClient(); + using var application = TestApplication.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + { + Microsoft.Extensions.DependencyInjection.OptionsServiceCollectionExtensions.PostConfigure( + services, options => options.MultipartBodyLengthLimit = 128); + Microsoft.Extensions.DependencyInjection.OptionsServiceCollectionExtensions.PostConfigure( + services, options => options.SuppressModelStateInvalidFilter = false); + })); + using var httpClient = application.CreateClient(); + var storageClient = new ManagedCode.Storage.Client.StorageClient(httpClient); var contentName = "file"; await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); FileHelper.GenerateLocalFile(localFile, 200); // Act - var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName); + var result = await storageClient.UploadFile(localFile.FileStream, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); // Assert result.IsFailed @@ -78,7 +87,7 @@ public async Task UploadFileFromFileInfo_WhenFileValid_ReturnSuccess() FileHelper.GenerateLocalFile(localFile, 1); // Act - var result = await storageClient.UploadFile(localFile.FileInfo, _uploadEndpoint, contentName); + var result = await storageClient.UploadFile(localFile.FileInfo, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -96,10 +105,10 @@ public async Task UploadFileFromBytes_WhenFileValid_ReturnSuccess() await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); FileHelper.GenerateLocalFile(localFile, 1); - var fileAsBytes = await localFile.ReadAllBytesAsync(); + var fileAsBytes = await localFile.ReadAllBytesAsync(TestContext.Current.CancellationToken); // Act - var result = await storageClient.UploadFile(fileAsBytes, _uploadEndpoint, contentName); + var result = await storageClient.UploadFile(fileAsBytes, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -118,11 +127,11 @@ public async Task UploadFileFromBase64String_WhenFileValid_ReturnSuccess() await using var localFile = LocalFile.FromRandomNameWithExtension(".txt"); FileHelper.GenerateLocalFile(localFile, 1); - var fileAsBytes = await localFile.ReadAllBytesAsync(); + var fileAsBytes = await localFile.ReadAllBytesAsync(TestContext.Current.CancellationToken); var fileAsString64 = Convert.ToBase64String(fileAsBytes); // Act - var result = await storageClient.UploadFile(fileAsString64, _uploadEndpoint, contentName); + var result = await storageClient.UploadFile(fileAsString64, _uploadEndpoint, contentName, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -143,7 +152,7 @@ public async Task UploadLargeFile_WhenFileValid_ReturnSuccess() storageClient.SetChunkSize(4096000); // Act - var result = await storageClient.UploadLargeFile(localFile.FileStream, _uploadLargeFile + "/upload", _uploadLargeFile + "/complete", null); + var result = await storageClient.UploadLargeFile(localFile.FileStream, _uploadLargeFile + "/upload", _uploadLargeFile + "/complete", null, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -163,7 +172,7 @@ public async Task UploadFileFromStream_WhenFileIsLarge_ShouldRoundTrip(int gigab var downloadEndpoint = $"{ApiEndpoint}/download"; var sizeBytes = LargeFileTestHelper.ResolveSizeBytes(gigabytes); - await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin"); + await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin", cancellationToken: TestContext.Current.CancellationToken); var expectedCrc = LargeFileTestHelper.CalculateFileCrc(localFile.FilePath); await using (var readStream = File.OpenRead(localFile.FilePath)) @@ -200,7 +209,7 @@ public async Task UploadLargeFile_WhenFileIsLarge_ReturnsExpectedChecksum(int gi var sizeBytes = LargeFileTestHelper.ResolveSizeBytes(gigabytes); - await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin"); + await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin", cancellationToken: TestContext.Current.CancellationToken); var expectedCrc = LargeFileTestHelper.CalculateFileCrc(localFile.FilePath); var fileName = Path.GetFileName(localFile.FilePath); diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/AzureSignalRStorageTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/AzureSignalRStorageTests.cs index 8181332..e40057c 100644 --- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/AzureSignalRStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/AzureSignalRStorageTests.cs @@ -13,7 +13,6 @@ using Shouldly; using Xunit; using Microsoft.AspNetCore.SignalR; -using Xunit.Abstractions; namespace ManagedCode.Storage.Tests.AspNetTests.Azure; @@ -84,7 +83,7 @@ public async Task UploadStreamAsync_WhenFileProvided_ShouldStoreBlob() status.IsCompleted.ShouldBeTrue(); status.Metadata.ShouldNotBeNull(); - var exists = await storage.ExistsAsync(status.Metadata!.FullName ?? status.Metadata.Name ?? descriptor.FileName); + var exists = await storage.ExistsAsync(status.Metadata!.FullName ?? status.Metadata.Name ?? descriptor.FileName, TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); @@ -92,8 +91,8 @@ public async Task UploadStreamAsync_WhenFileProvided_ShouldStoreBlob() lastProgress!.IsCompleted.ShouldBeTrue(); lastProgress.BytesTransferred.ShouldBeGreaterThan(0); - await storage.DeleteAsync(status.Metadata.FullName ?? status.Metadata.Name ?? descriptor.FileName); - await client.DisconnectAsync(); + await storage.DeleteAsync(status.Metadata.FullName ?? status.Metadata.Name ?? descriptor.FileName, TestContext.Current.CancellationToken); + await client.DisconnectAsync(TestContext.Current.CancellationToken); } [Fact] @@ -120,12 +119,12 @@ public async Task DownloadStreamAsync_WhenBlobExists_ShouldDownloadContent() var expectedCrc = Crc32Helper.CalculateFileCrc(localFile.FilePath); memory.Position = 0; - await using var downloadedFile = await LocalFile.FromStreamAsync(memory, Environment.CurrentDirectory, Guid.NewGuid().ToString("N") + localFile.FileInfo.Extension); + await using var downloadedFile = await LocalFile.FromStreamAsync(memory, Environment.CurrentDirectory, Guid.NewGuid().ToString("N") + localFile.FileInfo.Extension, TestContext.Current.CancellationToken); var downloadedCrc = Crc32Helper.CalculateFileCrc(downloadedFile.FilePath); downloadedCrc.ShouldBe(expectedCrc); - await storage.DeleteAsync(localFile.FileInfo.Name); - await client.DisconnectAsync(); + await storage.DeleteAsync(localFile.FileInfo.Name, TestContext.Current.CancellationToken); + await client.DisconnectAsync(TestContext.Current.CancellationToken); } [Theory] @@ -137,7 +136,7 @@ public async Task UploadStreamAsync_WhenFileIsLarge_ShouldRoundTrip(int gigabyte { var sizeBytes = LargeFileTestHelper.ResolveSizeBytes(gigabytes); - await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin"); + await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin", cancellationToken: TestContext.Current.CancellationToken); var expectedCrc = LargeFileTestHelper.CalculateFileCrc(localFile.FilePath); var descriptor = CreateDescriptor(Path.GetFileName(localFile.FilePath), "application/octet-stream", sizeBytes); @@ -174,6 +173,6 @@ public async Task UploadStreamAsync_WhenFileIsLarge_ShouldRoundTrip(int gigabyte downloadedCrc.ShouldBe(expectedCrc); await storage.DeleteAsync(remoteName, CancellationToken.None); - await client.DisconnectAsync(); + await client.DisconnectAsync(TestContext.Current.CancellationToken); } } diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs new file mode 100644 index 0000000..d32071b --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Azure; +using ManagedCode.Storage.Client.SignalR; +using ManagedCode.Storage.Client.SignalR.Models; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.AspNetTests.Abstracts; +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.Tests.Constants; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.AspNetTests.Azure; + +public sealed class SignalRRuntimeContractsTests(StorageTestApplication app) : BaseSignalRStorageTests(app, ApiEndpoints.Azure) +{ + [Fact] + public async Task ProgressStatusAndStreaming_DeliverTheStoredBytesAndResourceIdentity() + { + var token = TestContext.Current.CancellationToken; + var name = Guid.NewGuid().ToString("N") + ".bin"; + var bytes = RandomNumberGenerator.GetBytes(131079); + await using var client = CreateClient(); + await client.ConnectAsync(token); + await client.ConnectAsync(token); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var uploadProgress = new StatusRecorder(s => { if (s.BytesTransferred > 0 && !s.IsCompleted) started.TrySetResult(s); }); + await using var input = new GatedReadStream(bytes); + var descriptor = CreateDescriptor(name, "application/octet-stream", bytes.Length); + var upload = client.UploadAsync(input, descriptor, uploadProgress, token); + try + { + var active = await started.Task.WaitAsync(TimeSpan.FromSeconds(30), token); + var liveStatus = await client.GetStatusAsync(active.TransferId, token); + liveStatus.ShouldNotBeNull(); + liveStatus!.IsCompleted.ShouldBeFalse(); + liveStatus.BytesTransferred.ShouldBeGreaterThan(0); + } + finally { input.Resume(); } + var status = await upload; + status.IsCompleted.ShouldBeTrue(); + uploadProgress.Statuses.ShouldNotBeEmpty(); + uploadProgress.Statuses.All(s => s.TransferId == status.TransferId).ShouldBeTrue(); + var downloadProgress = new StatusRecorder(); + await using var destination = new MemoryStream(); + (await client.DownloadAsync(name, destination, downloadProgress, token)).IsCompleted.ShouldBeTrue(); + destination.ToArray().ShouldBe(bytes); + downloadProgress.Statuses.ShouldNotBeEmpty(); + downloadProgress.Statuses.All(s => s.ResourceName == name).ShouldBeTrue(); + await using var chunks = new MemoryStream(); + await foreach (var chunk in client.DownloadStreamAsync(name, token)) await chunks.WriteAsync(chunk, token); + chunks.ToArray().ShouldBe(bytes); + (await client.GetStatusAsync("missing-transfer", token)).ShouldBeNull(); + await client.CancelTransferAsync(status.TransferId, token); + await using var scope = TestApplication.Services.CreateAsyncScope(); + (await scope.ServiceProvider.GetRequiredService().DeleteAsync(name, token)).IsSuccess.ShouldBeTrue(); + await client.DisconnectAsync(token); + client.IsConnected.ShouldBeFalse(); + } + + [Fact] + public async Task MissingDownload_PropagatesTheRealHubFailure() + { + await using var client = CreateClient(); + var token = TestContext.Current.CancellationToken; + await client.ConnectAsync(token); + await using var output = new MemoryStream(); + await Should.ThrowAsync(() => client.DownloadAsync("missing-" + Guid.NewGuid().ToString("N"), output, new StatusRecorder(), token)); + output.Length.ShouldBe(0); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task InvalidTransferLimits_AreRejectedBeforePublishingAnUpload(int invalid) + { + await using var client = CreateClient(options => { if (invalid == 0) options.StreamBufferSize = 0; else options.UploadChannelCapacity = 0; }); + var token = TestContext.Current.CancellationToken; + await client.ConnectAsync(token); + await using var input = new MemoryStream(new byte[] { 1 }); + await Should.ThrowAsync(() => client.UploadAsync(input, CreateDescriptor("invalid.bin", "application/octet-stream", 1), cancellationToken: token)); + } + + private sealed class StatusRecorder(Action? received = null) : IProgress + { + public ConcurrentQueue Statuses { get; } = new(); + public void Report(StorageTransferStatus value) { Statuses.Enqueue(value); received?.Invoke(value); } + } + + private sealed class GatedReadStream(byte[] bytes) : MemoryStream(bytes) + { + private readonly TaskCompletionSource _resume = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _reads; + public void Resume() => _resume.TrySetResult(true); + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _reads) > 1) await _resume.Task.WaitAsync(cancellationToken); + return await base.ReadAsync(buffer, cancellationToken); + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/DisableFormBindingAttribute.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/DisableFormBindingAttribute.cs new file mode 100644 index 0000000..f4acd66 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/DisableFormBindingAttribute.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +[AttributeUsage(AttributeTargets.Method)] +internal sealed class DisableFormBindingAttribute : Attribute, IResourceFilter +{ + public void OnResourceExecuting(ResourceExecutingContext context) + { + context.ValueProviderFactories.RemoveType(); + context.ValueProviderFactories.RemoveType(); + context.ValueProviderFactories.RemoveType(); + } + + public void OnResourceExecuted(ResourceExecutedContext context) { } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/HttpChunkRejectionTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/HttpChunkRejectionTests.cs new file mode 100644 index 0000000..a0d5d5e --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/HttpChunkRejectionTests.cs @@ -0,0 +1,22 @@ +using System.IO; +using System.Threading.Tasks; +using ManagedCode.Storage.Client; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +public sealed class HttpChunkRejectionTests(StorageHttpRuntimeHost host) : IClassFixture +{ + [Fact] + public async Task OversizedChunk_ReturnsTheServerRejectionWithoutCompletingAnUpload() + { + var client = new StorageClient(host.Client); + client.SetChunkSize(8193); + using var stream = new MemoryStream(new byte[8193]); + var result = await client.UploadLargeFile(stream, "runtime/storage/upload-chunks/upload", + "runtime/storage/upload-chunks/complete", null, TestContext.Current.CancellationToken); + result.IsFailed.ShouldBeTrue(); + result.Problem!.StatusCode.ShouldBe(413); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RawMultipartRuntimeTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RawMultipartRuntimeTests.cs new file mode 100644 index 0000000..7266956 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RawMultipartRuntimeTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +public sealed class RawMultipartRuntimeTests(StorageHttpRuntimeHost host) : IClassFixture +{ + [Fact] + public async Task RawMultipart_SkipsFieldsAndPersistsTheActualFile() + { + var bytes = Encoding.UTF8.GetBytes("raw multipart Привіт"); + var name = Guid.NewGuid().ToString("N") + ".txt"; + using var content = new MultipartFormDataContent(); + content.Add(new StringContent("description"), "note"); + content.Add(new ByteArrayContent(bytes), "file", name); + var token = TestContext.Current.CancellationToken; + using var response = await host.Client.PostAsync("runtime/storage/upload/raw-multipart", content, token); + response.StatusCode.ShouldBe(HttpStatusCode.OK, await response.Content.ReadAsStringAsync(token)); + var metadata = await response.Content.ReadFromJsonAsync(cancellationToken: token); + metadata!.Name.ShouldBe(name); + (await host.Client.GetByteArrayAsync("runtime/storage/stream/" + name, token)).ShouldBe(bytes); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task InvalidMultipart_ReturnsARequestError(int invalid) + { + using var content = new MultipartFormDataContent(); + content.Add(new StringContent("field without a file"), "note"); + if (invalid == 0) content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain"); + if (invalid == 1) content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("multipart/form-data"); + if (invalid == 2) + { + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("multipart/form-data"); + content.Headers.ContentType.Parameters.Add(new System.Net.Http.Headers.NameValueHeaderValue("boundary", new string('b', 71))); + } + using var response = await host.Client.PostAsync("runtime/storage/upload/raw-multipart", content, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RuntimeStorageController.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RuntimeStorageController.cs new file mode 100644 index 0000000..b2b38e9 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/RuntimeStorageController.cs @@ -0,0 +1,24 @@ +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Server.ChunkUpload; +using ManagedCode.Storage.Server.Controllers; +using Microsoft.AspNetCore.Mvc; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +[Route("runtime/storage")] +public sealed class RuntimeStorageController(IStorage storage, ChunkUploadService chunks, StorageServerOptions options) + : StorageControllerBase(storage, chunks, options) +{ + [HttpPost("upload/raw-multipart")] + [DisableFormBinding] + public async System.Threading.Tasks.Task UploadRawMultipartAsync(System.Threading.CancellationToken cancellationToken) + { + try + { + var metadata = await ManagedCode.Storage.Server.Extensions.Controller.ControllerUploadExtensions.UploadFromStreamAsync( + this, Storage, Request, cancellationToken: cancellationToken); + return Ok(metadata); + } + catch (System.Exception exception) { return BadRequest(exception.Message); } + } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageControllerRuntimeTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageControllerRuntimeTests.cs new file mode 100644 index 0000000..56a2b62 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageControllerRuntimeTests.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Storage.Client; +using ManagedCode.Storage.Core.Helpers; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Server.Controllers; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +public sealed class StorageControllerRuntimeTests(StorageHttpRuntimeHost host) : IClassFixture +{ + private const string Root = "runtime/storage/"; + + [Theory] + [InlineData(0, 3)] + [InlineData(1, 4096)] + [InlineData(2, 4096)] + [InlineData(3, 4096)] + public async Task UploadOverloads_AndAllDownloadRoutes_PreserveBytes(int overload, int length) + { + var bytes = new byte[length]; + Random.Shared.NextBytes(bytes); + var client = new StorageClient(host.Client); + var token = TestContext.Current.CancellationToken; + Result result; + if (overload == 0) result = await client.UploadFile(bytes, Root + "upload", "file", token); + else if (overload == 1) result = await client.UploadFile(Convert.ToBase64String(bytes), Root + "upload", "file", token); + else if (overload == 2) + { + await using var local = await LocalFile.FromStreamAsync(new MemoryStream(bytes), token); + result = await client.UploadFile(local.FileInfo, Root + "upload", "file", token); + } + else + { + using var stream = new MemoryStream(bytes); + result = await client.UploadFile(stream, Root + "upload", "file", token); + } + result.IsSuccess.ShouldBeTrue(result.Problem?.ToString()); + var name = result.Value!.FullName!; + foreach (var route in new[] { "download", "stream", "download-bytes" }) + { + using var response = await host.Client.GetAsync(Root + route + "/" + name, token); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + (await response.Content.ReadAsByteArrayAsync(token)).ShouldBe(bytes); + } + var downloaded = await client.DownloadFile(name, Root + "download", cancellationToken: token); + downloaded.IsSuccess.ShouldBeTrue(); + await using var file = downloaded.Value!; + (await file.ReadAllBytesAsync(token)).ShouldBe(bytes); + var opened = await client.GetFileStream(name, Root + "stream", token); + opened.IsSuccess.ShouldBeTrue(); + await using var openedStream = opened.Value!; + using var output = new MemoryStream(); + await openedStream.CopyToAsync(output, token); + output.ToArray().ShouldBe(bytes); + } + + [Fact] + public async Task RawUpload_AndHttpRanges_PreserveRequestedBytes() + { + var bytes = new byte[1234]; + Random.Shared.NextBytes(bytes); + var name = Guid.NewGuid().ToString("N") + ".bin"; + var token = TestContext.Current.CancellationToken; + using var request = new HttpRequestMessage(HttpMethod.Post, Root + "upload/stream") { Content = new ByteArrayContent(bytes) }; + request.Headers.Add(StorageServerHeaders.FileName, name); + request.Headers.Add(StorageServerHeaders.ContentType, "application/octet-stream"); + request.Headers.Add(StorageServerHeaders.Directory, "nested"); + using var uploaded = await host.Client.SendAsync(request, token); + (await uploaded.Content.ReadFromJsonAsync>(cancellationToken: token))!.IsSuccess.ShouldBeTrue(); + foreach (var route in new[] { "download", "stream", "download-bytes" }) + { + using var range = new HttpRequestMessage(HttpMethod.Get, Root + route + "/nested/" + name); + range.Headers.Range = new RangeHeaderValue(3, 17); + using var response = await host.Client.SendAsync(range, token); + response.StatusCode.ShouldBe(HttpStatusCode.PartialContent); + (await response.Content.ReadAsByteArrayAsync(token)).ShouldBe(bytes[3..18]); + } + } + + [Fact] + public async Task ChunkUpload_CompletesWithMatchingChecksumAndPersistedBytes() + { + var bytes = new byte[9237]; + Random.Shared.NextBytes(bytes); + var client = new StorageClient(host.Client); + client.SetChunkSize(1024); + double progress = 0; + using var stream = new MemoryStream(bytes); + var completed = await client.UploadLargeFile(stream, Root + "upload-chunks/upload", Root + "upload-chunks/complete", + value => progress = value, TestContext.Current.CancellationToken); + completed.IsSuccess.ShouldBeTrue(System.Text.Json.JsonSerializer.Serialize(completed.Problem)); + completed.Value.ShouldBe(Crc32Helper.Calculate(bytes)); + progress.ShouldBe(100); + var found = false; + await foreach (var metadata in host.Storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) + { + if (metadata.Length != (ulong)bytes.Length) continue; + var downloaded = await host.Storage.DownloadAsync(metadata.FullName!, TestContext.Current.CancellationToken); + await using var file = downloaded.Value!; + (await file.ReadAllBytesAsync(TestContext.Current.CancellationToken)).ShouldBe(bytes); + found = true; + } + found.ShouldBeTrue(); + } + + [Fact] + public async Task InvalidUploads_MissingDownloadsAndAbort_ReportErrors() + { + var token = TestContext.Current.CancellationToken; + var client = new StorageClient(host.Client); + var oversized = await client.UploadFile(new byte[16 * 1024 + 1], Root + "upload", "file", token); + oversized.IsFailed.ShouldBeTrue(); + oversized.Problem!.StatusCode.ShouldBe(413); + using var request = new HttpRequestMessage(HttpMethod.Post, Root + "upload/stream") { Content = new ByteArrayContent(new byte[1]) }; + using var missingHeader = await host.Client.SendAsync(request, token); + (await missingHeader.Content.ReadFromJsonAsync>(cancellationToken: token))!.IsFailed.ShouldBeTrue(); + foreach (var route in new[] { "download", "stream", "download-bytes" }) + { + using var response = await host.Client.GetAsync(Root + route + "/missing", token); + response.IsSuccessStatusCode.ShouldBeFalse(); + } + (await client.GetFileStream("missing", Root + "stream", token)).IsFailed.ShouldBeTrue(); + (await client.DownloadFile("missing", Root + "download", cancellationToken: token)).IsFailed.ShouldBeTrue(); + using var invalidChunk = new MultipartFormDataContent(); + invalidChunk.Add(new ByteArrayContent(new byte[1]), "File", "file"); + using var chunkResponse = await host.Client.PostAsync(Root + "upload-chunks/upload", invalidChunk, token); + (await chunkResponse.Content.ReadFromJsonAsync(cancellationToken: token))!.IsFailed.ShouldBeTrue(); + using var aborted = await host.Client.DeleteAsync(Root + "upload-chunks/absent", token); + aborted.StatusCode.ShouldBe(HttpStatusCode.NoContent); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs new file mode 100644 index 0000000..b498256 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.FileSystem.Extensions; +using ManagedCode.Storage.FileSystem.Options; +using ManagedCode.Storage.Server.Controllers; +using ManagedCode.Storage.Server.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.AspNetTests.Runtime; + +public sealed class StorageHttpRuntimeHost : IAsyncLifetime +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "storage-http", Guid.NewGuid().ToString("N")); + private WebApplication _app = null!; + public HttpClient Client { get; private set; } = null!; + public IStorage Storage => _app.Services.GetRequiredService(); + + public async ValueTask InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddControllers().AddApplicationPart(typeof(RuntimeStorageController).Assembly); + builder.Services.AddFileSystemStorageAsDefault(new FileSystemStorageOptions { BaseFolder = _root }); + builder.Services.AddStorageServer(); + builder.Services.AddSingleton(new StorageServerOptions + { + MaxFileSize = 16 * 1024, + MaxChunkSize = 8 * 1024, + InMemoryUploadThresholdBytes = 4 + }); + _app = builder.Build(); + _app.MapControllers(); + await _app.StartAsync(TestContext.Current.CancellationToken); + (await Storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var endpoint = _app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + Client = new HttpClient { BaseAddress = new Uri(endpoint) }; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.DisposeAsync(); + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Common/BaseContainer.cs b/Tests/ManagedCode.Storage.Tests/Common/BaseContainer.cs index 280aebc..a84cfbe 100644 --- a/Tests/ManagedCode.Storage.Tests/Common/BaseContainer.cs +++ b/Tests/ManagedCode.Storage.Tests/Common/BaseContainer.cs @@ -19,7 +19,7 @@ public abstract class BaseContainer : IAsyncLifetime where T : IContainer protected ServiceProvider ServiceProvider { get; private set; } = default!; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { Container = Build(); await Container.StartAsync(); @@ -27,10 +27,10 @@ public async Task InitializeAsync() Storage = ServiceProvider.GetService()!; } - public Task DisposeAsync() + public async ValueTask DisposeAsync() { - return Container.DisposeAsync() - .AsTask(); + await Container.DisposeAsync(); + GC.SuppressFinalize(this); } protected abstract T Build(); diff --git a/Tests/ManagedCode.Storage.Tests/Common/ContainerImages.cs b/Tests/ManagedCode.Storage.Tests/Common/ContainerImages.cs index 8aa09c3..10c847a 100644 --- a/Tests/ManagedCode.Storage.Tests/Common/ContainerImages.cs +++ b/Tests/ManagedCode.Storage.Tests/Common/ContainerImages.cs @@ -2,8 +2,8 @@ namespace ManagedCode.Storage.Tests.Common; public class ContainerImages { - public const string Azurite = "mcr.microsoft.com/azure-storage/azurite:latest"; - public const string FakeGCSServer = "fsouza/fake-gcs-server:latest"; + public const string Azurite = "mcr.microsoft.com/azure-storage/azurite:3.37.0"; + public const string FakeGCSServer = "fsouza/fake-gcs-server:1.56.1"; public const string LocalStack = "localstack/localstack:4.14.0"; - public const string Sftp = "atmoz/sftp:alpine"; + public const string Sftp = "atmoz/sftp:alpine@sha256:7e439fabeae536fa254340724c042e11999924d906417dbb5c73602ad518813f"; } diff --git a/Tests/ManagedCode.Storage.Tests/Common/GcsContainerFactory.cs b/Tests/ManagedCode.Storage.Tests/Common/GcsContainerFactory.cs new file mode 100644 index 0000000..bdb9b87 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Common/GcsContainerFactory.cs @@ -0,0 +1,34 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Configurations; +using Testcontainers.FakeGcsServer; + +namespace ManagedCode.Storage.Tests.Common; + +internal static class GcsContainerFactory +{ + private const string StagedScript = "/storage-start.partial"; + private const string ReadyScript = "/storage-start.sh"; + + public static FakeGcsServerContainer Create(string backend = "memory") + { + if (backend is not ("memory" or "filesystem")) throw new ArgumentException("Unsupported GCS backend", nameof(backend)); + return new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) + .WithCommand(new OverwriteEnumerable([$"while [ ! -f {ReadyScript} ]; do sleep 0.1; done; exec /bin/sh {ReadyScript}"])) + .WithStartupCallback((container, token) => StageStartupAsync(container, backend, token)) + .Build(); + } + + private static async Task StageStartupAsync(FakeGcsServerContainer container, string backend, CancellationToken token) + { + // Publish only after CopyAsync has closed the file; executing during extraction causes ETXTBSY. + var endpoint = new UriBuilder(Uri.UriSchemeHttp, container.Hostname, + container.GetMappedPublicPort(FakeGcsServerBuilder.FakeGcsServerPort)).ToString().TrimEnd('/'); + var script = $"exec fake-gcs-server -backend {backend} -scheme http -external-url '{endpoint}'\n"; + await container.CopyAsync(Encoding.UTF8.GetBytes(script), StagedScript, ct: token); + var result = await container.ExecAsync(["mv", StagedScript, ReadyScript], token); + if (result.ExitCode != 0) throw new InvalidOperationException($"Failed to publish GCS startup script: {result.Stderr}"); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Common/LargeFileTestHelper.cs b/Tests/ManagedCode.Storage.Tests/Common/LargeFileTestHelper.cs index b8af190..65ac74e 100644 --- a/Tests/ManagedCode.Storage.Tests/Common/LargeFileTestHelper.cs +++ b/Tests/ManagedCode.Storage.Tests/Common/LargeFileTestHelper.cs @@ -7,7 +7,6 @@ using ManagedCode.Storage.Core.Helpers; using ManagedCode.Storage.Core.Models; using Xunit; -using Xunit.Abstractions; namespace ManagedCode.Storage.Tests.Common; diff --git a/Tests/ManagedCode.Storage.Tests/Common/StorageTestApplication.cs b/Tests/ManagedCode.Storage.Tests/Common/StorageTestApplication.cs index d35fa2f..c76c12a 100644 --- a/Tests/ManagedCode.Storage.Tests/Common/StorageTestApplication.cs +++ b/Tests/ManagedCode.Storage.Tests/Common/StorageTestApplication.cs @@ -46,8 +46,7 @@ public StorageTestApplication() _localStackContainer = new LocalStackBuilder(ContainerImages.LocalStack) .Build(); - _gcpContainer = new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + _gcpContainer = GcsContainerFactory.Create(); Task.WaitAll( _azuriteContainer.StartAsync(), @@ -56,6 +55,8 @@ public StorageTestApplication() ); } + protected override IHostBuilder CreateHostBuilder() => HttpHostProgram.CreateHostBuilder(Array.Empty()); + protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureLogging(logging => diff --git a/Tests/ManagedCode.Storage.Tests/Common/TestApp/HttpHostProgram.cs b/Tests/ManagedCode.Storage.Tests/Common/TestApp/HttpHostProgram.cs index 6ebb1ce..dfa4242 100644 --- a/Tests/ManagedCode.Storage.Tests/Common/TestApp/HttpHostProgram.cs +++ b/Tests/ManagedCode.Storage.Tests/Common/TestApp/HttpHostProgram.cs @@ -8,40 +8,40 @@ namespace ManagedCode.Storage.Tests.Common.TestApp; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + public class HttpHostProgram { - public static void Main(string[] args) + public static Microsoft.Extensions.Hosting.IHostBuilder CreateHostBuilder(string[] args) { - var options = new WebApplicationOptions - { - Args = args, - ContentRootPath = Directory.GetCurrentDirectory() - }; - var builder = WebApplication.CreateBuilder(options); - - builder.Services.AddControllers(); - builder.Services.AddSignalR(options => - { - options.EnableDetailedErrors = true; - options.MaximumReceiveMessageSize = 8L * 1024 * 1024; // 8 MB - }); - builder.Services.AddEndpointsApiExplorer(); - - // Configure form options for large file uploads - builder.Services.Configure(options => - { - options.ValueLengthLimit = int.MaxValue; - options.MultipartBodyLengthLimit = long.MaxValue; - options.MultipartHeadersLengthLimit = int.MaxValue; - }); - - - var app = builder.Build(); - - app.UseRouting(); - app.MapControllers(); - app.MapStorageHub(); - - app.Run(); + return Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(web => web + .ConfigureServices(services => + { + services.AddControllers(); + services.AddSignalR(options => + { + options.EnableDetailedErrors = true; + options.MaximumReceiveMessageSize = 8L * 1024 * 1024; + }); + services.Configure(options => + { + options.ValueLengthLimit = int.MaxValue; + options.MultipartBodyLengthLimit = long.MaxValue; + options.MultipartHeadersLengthLimit = int.MaxValue; + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapStorageHub(); + }); + })); } + + public static void Main(string[] args) => CreateHostBuilder(args).Build().Run(); } diff --git a/Tests/ManagedCode.Storage.Tests/Core/LocalFileContentContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Core/LocalFileContentContractsTests.cs new file mode 100644 index 0000000..627038d --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Core/LocalFileContentContractsTests.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Core; + +public sealed class LocalFileContentContractsTests +{ + [Fact] + public async Task ExistingExtensionlessFile_PreservesItsPathAndBytes() + { + var root = Path.Combine(Path.GetTempPath(), "extensionless-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var path = Path.Combine(root, "plain"); + var bytes = System.Text.Encoding.UTF8.GetBytes("extensionless content"); + await File.WriteAllBytesAsync(path, bytes, TestContext.Current.CancellationToken); + await using var local = new LocalFile(path, keepAlive: true); + local.FilePath.ShouldBe(path); + (await local.ReadAllBytesAsync(TestContext.Current.CancellationToken)).ShouldBe(bytes); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task StreamFactoriesAndContentApis_PersistExactData(int factory) + { + var token = TestContext.Current.CancellationToken; + await using var source = new Utf8StringStream("original content"); + var name = "local-contract-" + Guid.NewGuid().ToString("N") + ".txt"; + await using var file = factory switch + { + 0 => await LocalFile.FromStreamAsync(source, token), + 1 => await LocalFile.FromStreamAsync(source, name, token), + _ => await LocalFile.FromStreamAsync(source, Path.GetTempPath(), name, token) + }; + file.ReadAllText().ShouldBe("original content"); + using (var replacement = new Utf8StringStream("short")) + (await file.CopyFromStreamAsync(replacement, token)).ShouldBeSameAs(file); + (await file.ReadAllTextAsync(token)).ShouldBe("short"); + file.WriteAllText("text"); + (await file.ReadAllTextAsync(token)).ShouldBe("text"); + await file.WriteAllTextAsync("async text", token); + file.ReadAllText().ShouldBe("async text"); + var lines = new[] { "first", "Привіт" }; + file.WriteAllLines(lines); + file.ReadAllLines().ShouldBe(lines); + (await file.ReadAllLinesAsync(token)).ShouldBe(lines); + file.ReadLines().ToArray().ShouldBe(lines); + await file.WriteAllLinesAsync(new[] { "replacement" }, token); + file.ReadAllLines().ShouldBe(new[] { "replacement" }); + file.WriteAllBytes(new byte[] { 1, 2, 3 }); + file.ReadAllBytes().ShouldBe(new byte[] { 1, 2, 3 }); + await file.WriteAllBytesAsync(new byte[] { 4, 5 }, token); + (await file.ReadAllBytesAsync(token)).ShouldBe(new byte[] { 4, 5 }); + file.Close(); + file.FileInfo.Length.ShouldBe(2); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReadStream_ExposesRealCapabilitiesAndOwnership(bool disposeOwner) + { + var token = TestContext.Current.CancellationToken; + await using var file = LocalFile.FromTempFile(); + await file.WriteAllBytesAsync(new byte[] { 10, 20, 30, 40 }, token); + var stream = file.OpenReadStream(disposeOwner); + stream.CanRead.ShouldBeTrue(); + stream.CanSeek.ShouldBeTrue(); + stream.CanWrite.ShouldBeFalse(); + stream.Length.ShouldBe(4); + stream.ReadByte().ShouldBe(10); + stream.Position = 0; + var buffer = new byte[4]; + stream.Read(buffer, 0, 1).ShouldBe(1); + stream.Read(buffer.AsSpan(1, 1)).ShouldBe(1); + (await stream.ReadAsync(buffer, 2, 1, token)).ShouldBe(1); + (await stream.ReadAsync(buffer.AsMemory(3, 1), token)).ShouldBe(1); + buffer.ShouldBe(new byte[] { 10, 20, 30, 40 }); + stream.Seek(-1, SeekOrigin.End).ShouldBe(3); + stream.Flush(); + await stream.FlushAsync(token); + Should.Throw(() => stream.Write(buffer, 0, 1)); + Should.Throw(() => stream.Write(buffer.AsSpan())); + await Should.ThrowAsync(() => stream.WriteAsync(buffer, 0, 1, token)); + await Should.ThrowAsync(async () => await stream.WriteAsync(buffer.AsMemory(), token)); + Should.Throw(() => stream.SetLength(1)); + await stream.DisposeAsync(); + await stream.DisposeAsync(); + File.Exists(file.FilePath).ShouldBe(!disposeOwner); + stream.CanRead.ShouldBeFalse(); + if (!disposeOwner) + file.ReadAllBytes().ShouldBe(buffer); + } + + [Fact] + public void MissingBackingFile_IsReportedAsFileNotFound() + { + using var file = LocalFile.FromRandomNameWithExtension("file.dat"); + Path.GetExtension(file.FilePath).ShouldBe(".dat"); + File.Delete(file.FilePath); + Should.Throw(() => _ = file.FileStream); + Should.Throw(() => file.OpenReadStream()); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Core/LocalFileTests.cs b/Tests/ManagedCode.Storage.Tests/Core/LocalFileTests.cs index 68c4c2c..10e35f8 100644 --- a/Tests/ManagedCode.Storage.Tests/Core/LocalFileTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Core/LocalFileTests.cs @@ -23,7 +23,7 @@ public async Task OpenReadStream_DisposeOwnerByDefault_DeletesBackingFile() await using (var stream = localFile.OpenReadStream()) { var buffer = new byte[payload.Length]; - var read = await stream.ReadAsync(buffer, 0, buffer.Length); + var read = await stream.ReadAsync(buffer, 0, buffer.Length, TestContext.Current.CancellationToken); read.ShouldBe(buffer.Length); buffer.ShouldBe(payload); } @@ -43,7 +43,7 @@ public async Task OpenReadStream_DisposeOwnerFalse_PreservesBackingFile() await using (var stream = localFile.OpenReadStream(disposeOwner: false)) { var reader = new StreamReader(stream, leaveOpen: false); - var text = await reader.ReadToEndAsync(); + var text = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); text.ShouldBe("pong"); } diff --git a/Tests/ManagedCode.Storage.Tests/Core/PathHelperContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Core/PathHelperContractsTests.cs new file mode 100644 index 0000000..82ffa53 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Core/PathHelperContractsTests.cs @@ -0,0 +1,76 @@ +using System.IO; +using ManagedCode.Storage.Core.Helpers; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Core; + +public sealed class PathHelperContractsTests +{ + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("folder\\nested/file.txt", "folder/nested/file.txt")] + public void NormalizePaths_ProducesTheRequestedSeparator(string? input, string expected) + { + PathHelper.ToUnixPath(input).ShouldBe(expected); + PathHelper.ToWindowsPath(input).ShouldBe(expected.Replace('/', '\\')); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("/root", true)] + [InlineData("\\root", true)] + [InlineData("C:\\root", true)] + [InlineData("1:relative", false)] + [InlineData("relative", false)] + public void IsAbsolutePath_DistinguishesPortableRoots(string? path, bool expected) + { + PathHelper.IsAbsolutePath(path).ShouldBe(expected); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("file.txt", "file.txt")] + [InlineData("folder\\nested/file.txt", "file.txt")] + [InlineData("folder/", "")] + public void GetFileName_HandlesMixedSeparators(string? path, string expected) + { + PathHelper.GetFileName(path).ShouldBe(expected); + } + + [Fact] + public void CombinePaths_HandlesEmptySegmentsAndRoot() + { + PathHelper.CombineUnixPaths().ShouldBeEmpty(); + PathHelper.CombineUnixPaths(null!).ShouldBeEmpty(); + PathHelper.CombineUnixPaths("/", "", "/folder", "\\file.txt").ShouldBe("/folder/file.txt"); + PathHelper.CombineWindowsPaths("C:", "folder", "file.txt").ShouldBe("C:\\folder\\file.txt"); + PathHelper.CombineUnixPaths(null!, "folder", "file.txt").ShouldBe("folder/file.txt"); + } + + [Theory] + [InlineData(null, "", "/", "")] + [InlineData("", "", "/", "")] + [InlineData("/", "", "/", "/")] + [InlineData("\\folder\\file/", "folder\\file/", "/folder/file/", "\\folder\\file")] + public void RootAndTrailingSeparatorHelpers_PreserveRootContracts( + string? input, string relative, string absolute, string trimmed) + { + PathHelper.EnsureRelativePath(input).ShouldBe(relative); + PathHelper.EnsureAbsolutePath(input).ShouldBe(absolute); + PathHelper.TrimTrailingSeparators(input).ShouldBe(trimmed); + } + + [Fact] + public void DirectoryHelpers_ExtractPlatformPathsAndNormalizeOutput() + { + var path = Path.Combine("folder", "nested", "file.txt"); + PathHelper.GetUnixDirectoryPath(path).ShouldBe("folder/nested"); + PathHelper.GetDirectoryPath(path, '\\').ShouldBe("folder\\nested"); + PathHelper.GetDirectoryPath(null).ShouldBeEmpty(); + PathHelper.GetDirectoryPath("").ShouldBeEmpty(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Core/StorageClientChunkTests.cs b/Tests/ManagedCode.Storage.Tests/Core/StorageClientChunkTests.cs index f7dc721..8639d7a 100644 --- a/Tests/ManagedCode.Storage.Tests/Core/StorageClientChunkTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Core/StorageClientChunkTests.cs @@ -64,7 +64,7 @@ public async Task UploadLargeFile_WhenServerReturnsObject_ShouldParseChecksum() { progressEvents.Add(progress); finalProgress = progress; - }); + }, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(expectedChecksum); @@ -105,7 +105,7 @@ public async Task UploadLargeFile_WhenServerReturnsNumber_ShouldParseChecksum() client.SetChunkSize(256 * 1024); using var payloadStream = new MemoryStream(payload, writable: false); - var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null); + var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(expectedChecksum); @@ -145,7 +145,7 @@ public async Task UploadLargeFile_WhenServerReturnsStringChecksum_ShouldParseChe }; using var payloadStream = new MemoryStream(payload, writable: false); - var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null); + var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(expectedChecksum); } @@ -178,7 +178,7 @@ public async Task UploadLargeFile_WhenValueMissing_ShouldFail() }; using var payloadStream = new MemoryStream(payload, writable: false); - var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null); + var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeFalse(); } @@ -222,7 +222,7 @@ public async Task UploadLargeFile_WhenServerReturnsZero_ShouldUseComputedChecksu }; using var payloadStream = new MemoryStream(payload, writable: false); - var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null); + var result = await client.UploadLargeFile(payloadStream, UploadUrl, CompleteUrl, null, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(expectedChecksum); } diff --git a/Tests/ManagedCode.Storage.Tests/Core/StorageClientDownloadTests.cs b/Tests/ManagedCode.Storage.Tests/Core/StorageClientDownloadTests.cs index c100682..42fd557 100644 --- a/Tests/ManagedCode.Storage.Tests/Core/StorageClientDownloadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Core/StorageClientDownloadTests.cs @@ -20,17 +20,17 @@ public async Task DownloadFile_DefaultDestinationKeepsSameNamedDownloadsIndepend using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://localhost") }; var client = new StorageClient(httpClient); - var firstResult = await client.DownloadFile("shared.bin", "/download"); + var firstResult = await client.DownloadFile("shared.bin", "/download", cancellationToken: TestContext.Current.CancellationToken); firstResult.IsSuccess.ShouldBeTrue(); await using var first = firstResult.Value!; - var secondResult = await client.DownloadFile("shared.bin", "/download"); + var secondResult = await client.DownloadFile("shared.bin", "/download", cancellationToken: TestContext.Current.CancellationToken); secondResult.IsSuccess.ShouldBeTrue(); await using var second = secondResult.Value!; first.FilePath.ShouldNotBe(second.FilePath); - (await File.ReadAllTextAsync(first.FilePath)).ShouldBe("first payload"); - (await File.ReadAllTextAsync(second.FilePath)).ShouldBe("second payload"); + (await File.ReadAllTextAsync(first.FilePath, TestContext.Current.CancellationToken)).ShouldBe("first payload"); + (await File.ReadAllTextAsync(second.FilePath, TestContext.Current.CancellationToken)).ShouldBe("second payload"); } private sealed class DownloadHandler : HttpMessageHandler diff --git a/Tests/ManagedCode.Storage.Tests/Core/StorageContainerCancellationContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Core/StorageContainerCancellationContractsTests.cs new file mode 100644 index 0000000..35049a2 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Core/StorageContainerCancellationContractsTests.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Tests.VirtualFileSystem; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Core; + +public sealed class StorageContainerCancellationContractsTests +{ + [Fact] + public async Task CanceledContainerCreation_ReturnsFailureAndPreservesTheCreationGate() + { + await using var session = await LocalVfsSession.CreateAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var canceled = await session.Storage.CreateContainerAsync(cancellation.Token); + canceled.IsFailed.ShouldBeTrue(); + var subsequent = await session.Storage.CreateContainerAsync(TestContext.Current.CancellationToken); + subsequent.IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Core/StringStreamContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Core/StringStreamContractsTests.cs new file mode 100644 index 0000000..ed844f5 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Core/StringStreamContractsTests.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text; +using ManagedCode.Storage.Core; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Core; + +public sealed class StringStreamContractsTests +{ + [Fact] + public void SeekAndRead_MatchUtf16BytesAndEndOfStream() + { + const string text = "Привіт 🌍"; + var bytes = Encoding.Unicode.GetBytes(text); + using var stream = new StringStream(text); + stream.ToString().ShouldBe(text); + stream.Length.ShouldBe(bytes.Length); + stream.Seek(3, SeekOrigin.Begin).ShouldBe(3); + stream.ReadByte().ShouldBe(bytes[3]); + stream.Seek(-2, SeekOrigin.Current).ShouldBe(2); + var buffer = new byte[5]; + stream.Read(buffer, 1, 3).ShouldBe(3); + buffer[1..4].ShouldBe(bytes[2..5]); + stream.Seek(-1, SeekOrigin.End).ShouldBe(bytes.Length - 1); + stream.ReadByte().ShouldBe(bytes[^1]); + stream.ReadByte().ShouldBe(-1); + stream.Seek(5, SeekOrigin.End); + stream.Read(buffer, 0, buffer.Length).ShouldBe(0); + stream.ReadByte().ShouldBe(-1); + Should.Throw(() => stream.SetLength(1)); + Should.Throw(() => stream.Write(buffer, 0, 1)); + Should.Throw(() => stream.Seek(0, (SeekOrigin)42)); + } + + [Theory] + [InlineData(-1, 1)] + [InlineData(0, -1)] + [InlineData(3, 2)] + public void InvalidReadBounds_AreRejectedWithoutMovingPosition(int offset, int count) + { + using var stream = new StringStream("content"); + Should.Throw(() => stream.Read(new byte[4], offset, count)); + stream.Position.ShouldBe(0); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Core/StringStreamTests.cs b/Tests/ManagedCode.Storage.Tests/Core/StringStreamTests.cs index 17d3039..c943ae0 100644 --- a/Tests/ManagedCode.Storage.Tests/Core/StringStreamTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Core/StringStreamTests.cs @@ -134,7 +134,7 @@ public async Task Utf8StringStream_ReadAsync_ShouldWork() // Act var buffer = new byte[stream.Length]; - var bytesRead = await stream.ReadAsync(buffer); + var bytesRead = await stream.ReadAsync(buffer, TestContext.Current.CancellationToken); // Assert bytesRead.ShouldBe(expectedBytes.Length); diff --git a/Tests/ManagedCode.Storage.Tests/Core/Utf8StringStreamContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Core/Utf8StringStreamContractsTests.cs new file mode 100644 index 0000000..0b63611 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Core/Utf8StringStreamContractsTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text; +using ManagedCode.Storage.Core; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Core; + +public sealed class Utf8StringStreamContractsTests +{ + [Theory] + [InlineData("")] + [InlineData("ASCII")] + [InlineData("Привіт 🌍")] + public async Task PooledAndEncodedStreams_RoundTripExactUtf8Bytes(string content) + { + using var stream = Utf8StringStream.CreatePooled(content, ArrayPool.Shared); + var expected = Encoding.UTF8.GetBytes(content); + stream.Length.ShouldBe(expected.Length); + stream.CanRead.ShouldBeTrue(); + stream.CanSeek.ShouldBeTrue(); + stream.CanWrite.ShouldBeFalse(); + stream.GetUtf8Bytes().ToArray().ShouldBe(expected); + stream.GetUtf8Span().ToArray().ShouldBe(expected); + stream.ToString().ShouldBe(content); + var actual = new byte[expected.Length + 2]; + (await stream.ReadAsync(actual.AsMemory(1, expected.Length), TestContext.Current.CancellationToken)).ShouldBe(expected.Length); + actual.Skip(1).Take(expected.Length).ShouldBe(expected); + stream.Read(actual.AsSpan()).ShouldBe(0); + stream.ReadByte().ShouldBe(-1); + stream.ToStringFromPosition().ShouldBeEmpty(); + await stream.FlushAsync(TestContext.Current.CancellationToken); + stream.Flush(); + } + + [Fact] + public void SeekAndByteReads_TrackTheUnreadContent() + { + using var stream = new Utf8StringStream("abc"); + stream.Seek(1, SeekOrigin.Begin).ShouldBe(1); + stream.ToStringFromPosition().ShouldBe("bc"); + stream.ReadByte().ShouldBe((int)'b'); + stream.Seek(-1, SeekOrigin.Current).ShouldBe(1); + stream.Seek(-1, SeekOrigin.End).ShouldBe(2); + stream.Position = 0; + var bytes = new byte[5]; + stream.Read(bytes, 1, 3).ShouldBe(3); + bytes.ShouldBe(new byte[] { 0, 97, 98, 99, 0 }); + Should.Throw(() => stream.Position = -1); + Should.Throw(() => stream.Position = 4); + Should.Throw(() => stream.Seek(-4, SeekOrigin.End)); + Should.Throw(() => stream.Seek(1, SeekOrigin.End)); + Should.Throw(() => stream.Seek(0, (SeekOrigin)99)); + } + + [Fact] + public async Task InvalidReadAndWriteRequests_RejectWithoutChangingPosition() + { + using var stream = new Utf8StringStream(new ReadOnlyMemory(new byte[] { 1, 2 })); + Should.Throw(() => stream.Read(null!, 0, 0)); + Should.Throw(() => stream.Read(new byte[2], -1, 1)); + Should.Throw(() => stream.Read(new byte[2], 0, -1)); + Should.Throw(() => stream.Read(new byte[2], 3, 0)); + Should.Throw(() => stream.Read(new byte[2], 1, 2)); + await Should.ThrowAsync(async () => + { + (await stream.ReadAsync(new byte[1], new CancellationToken(true))).ShouldBe(0); + }); + Should.Throw(() => stream.SetLength(0)); + Should.Throw(() => stream.Write(new byte[1], 0, 1)); + Should.Throw(() => stream.Write(new byte[1].AsSpan())); + await Should.ThrowAsync(async () => await stream.WriteAsync(new byte[1])); + Should.Throw(() => stream.WriteByte(1)); + Should.Throw(() => new Utf8StringStream((string)null!)); + Should.Throw(() => Utf8StringStream.CreatePooled(null!)); + stream.Position.ShouldBe(0); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/ExtensionsTests/FormFileExtensionsTests.cs b/Tests/ManagedCode.Storage.Tests/ExtensionsTests/FormFileExtensionsTests.cs index 337ac2b..64ad44a 100644 --- a/Tests/ManagedCode.Storage.Tests/ExtensionsTests/FormFileExtensionsTests.cs +++ b/Tests/ManagedCode.Storage.Tests/ExtensionsTests/FormFileExtensionsTests.cs @@ -22,7 +22,7 @@ public async Task ToLocalFileAsync_SmallFile() var formFile = FileHelper.GenerateFormFile(fileName, size); // Act - var localFile = await formFile.ToLocalFileAsync(); + var localFile = await formFile.ToLocalFileAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert localFile.FileStream.Length.ShouldBe(formFile.Length); @@ -38,7 +38,7 @@ public async Task ToLocalFileAsync_LargeFile() var formFile = FileHelper.GenerateFormFile(fileName, size); // Act - var localFile = await formFile.ToLocalFileAsync(); + var localFile = await formFile.ToLocalFileAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert localFile.FileStream.Length.ShouldBe(formFile.Length); @@ -61,7 +61,7 @@ public async Task ToLocalFilesAsync_SmallFiles() } // Act - var localFiles = await collection.ToLocalFilesAsync().ToListAsync(); + var localFiles = await collection.ToLocalFilesAsync(cancellationToken: TestContext.Current.CancellationToken).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert localFiles.Count.ShouldBe(filesCount); diff --git a/Tests/ManagedCode.Storage.Tests/ExtensionsTests/StorageExtensionsTests.cs b/Tests/ManagedCode.Storage.Tests/ExtensionsTests/StorageExtensionsTests.cs index f4f21a7..ca91500 100644 --- a/Tests/ManagedCode.Storage.Tests/ExtensionsTests/StorageExtensionsTests.cs +++ b/Tests/ManagedCode.Storage.Tests/ExtensionsTests/StorageExtensionsTests.cs @@ -44,8 +44,8 @@ public async Task UploadToStorageAsync_SmallFile() var formFile = FileHelper.GenerateFormFile(fileName, size); // Act - await Storage.UploadToStorageAsync(formFile); - var localFile = await Storage.DownloadAsync(fileName); + await Storage.UploadToStorageAsync(formFile, cancellationToken: TestContext.Current.CancellationToken); + var localFile = await Storage.DownloadAsync(fileName, TestContext.Current.CancellationToken); // Assert localFile.IsSuccess.ShouldBeTrue(); @@ -53,7 +53,7 @@ public async Task UploadToStorageAsync_SmallFile() downloaded.FileInfo.Length.ShouldBe(formFile.Length); downloaded.Name.ShouldBe(formFile.FileName); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -65,16 +65,16 @@ public async Task UploadToStorageAsync_LargeFile() var formFile = FileHelper.GenerateFormFile(fileName, size); // Act - var uploadResult = await Storage.UploadToStorageAsync(formFile); + var uploadResult = await Storage.UploadToStorageAsync(formFile, cancellationToken: TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); - var result = await Storage.DownloadAsync(fileName); + var result = await Storage.DownloadAsync(fileName, TestContext.Current.CancellationToken); // Assert result.IsSuccess.ShouldBeTrue(); var downloaded = result.Value ?? throw new InvalidOperationException("Download result is missing a file"); downloaded.Name.ShouldBe(formFile.FileName); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -86,10 +86,10 @@ public async Task UploadToStorageAsync_WithRandomName() var formFile = FileHelper.GenerateFormFile(fileName, size); // Act - var result = await Storage.UploadToStorageAsync(formFile); + var result = await Storage.UploadToStorageAsync(formFile, cancellationToken: TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var uploaded = result.Value ?? throw new InvalidOperationException("Upload result is missing metadata"); - var localFile = await Storage.DownloadAsync(uploaded.Name); + var localFile = await Storage.DownloadAsync(uploaded.Name, TestContext.Current.CancellationToken); // Assert localFile.IsSuccess.ShouldBeTrue(); @@ -97,7 +97,7 @@ public async Task UploadToStorageAsync_WithRandomName() downloaded.FileInfo.Length.ShouldBe(formFile.Length); downloaded.Name.ShouldBe(fileName); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -109,9 +109,9 @@ public async Task DownloadAsFileResult_WithFileName() var localFile = FileHelper.GenerateLocalFile(fileName, size); // Act - var uploadResult = await Storage.UploadAsync(localFile.FileInfo); + var uploadResult = await Storage.UploadAsync(localFile.FileInfo, TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); - var result = await Storage.DownloadAsFileResult(fileName); + var result = await Storage.DownloadAsFileResult(fileName, cancellationToken: TestContext.Current.CancellationToken); // Assert result.IsSuccess.ShouldBeTrue(); @@ -119,7 +119,7 @@ public async Task DownloadAsFileResult_WithFileName() fileResult.ContentType.ShouldBe(MimeHelper.GetMimeType(localFile.FileInfo.Extension)); fileResult.FileDownloadName.ShouldBe(localFile.Name); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -131,8 +131,8 @@ public async Task DownloadAsFileResult_WithBlobMetadata() var localFile = FileHelper.GenerateLocalFile(fileName, size); // Act - await Storage.UploadAsync(localFile.FileInfo, options => { options.FileName = fileName; }); - var result = await Storage.DownloadAsFileResult(fileName); + await Storage.UploadAsync(localFile.FileInfo, options => { options.FileName = fileName; }, TestContext.Current.CancellationToken); + var result = await Storage.DownloadAsFileResult(fileName, cancellationToken: TestContext.Current.CancellationToken); // Assert result.IsSuccess.ShouldBeTrue(); @@ -140,7 +140,7 @@ public async Task DownloadAsFileResult_WithBlobMetadata() fileResult.ContentType.ShouldBe(MimeHelper.GetMimeType(localFile.FileInfo.Extension)); fileResult.FileDownloadName.ShouldBe(localFile.Name); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -150,7 +150,7 @@ public async Task DownloadAsFileResult_WithFileName_IfFileDontExist() var fileName = FileHelper.GenerateRandomFileName(); // Act - var fileResult = await Storage.DownloadAsFileResult(fileName); + var fileResult = await Storage.DownloadAsFileResult(fileName, cancellationToken: TestContext.Current.CancellationToken); // Assert fileResult.IsSuccess.ShouldBeFalse(); @@ -165,7 +165,7 @@ public async Task DownloadAsFileResult_WithBlobMetadata_IfFileDontExist() BlobMetadata blobMetadata = new() { Name = fileName }; // Act - var fileResult = await Storage.DownloadAsFileResult(blobMetadata); + var fileResult = await Storage.DownloadAsFileResult(blobMetadata, cancellationToken: TestContext.Current.CancellationToken); // Assert fileResult.IsSuccess.ShouldBeFalse(); diff --git a/Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj b/Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj index 0ae17f7..c11bdcd 100644 --- a/Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj +++ b/Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj @@ -1,6 +1,9 @@ - + false + Exe + false + ManagedCode.Storage.Tests.StorageTestRunner false @@ -9,6 +12,15 @@ 1591 + + $(MSBuildThisFileDirectory)../../artifacts/coverage/coverage + json,cobertura,opencover + [ManagedCode.Storage.*]* + [ManagedCode.Storage.BrowserServerHost]*,[ManagedCode.Storage.BrowserWasmHost]* + 85,70 + line,branch + total + PreserveNewest @@ -36,15 +48,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadServiceTests.cs b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadServiceTests.cs index 1f75d97..01a12a3 100644 --- a/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadServiceTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadServiceTests.cs @@ -19,7 +19,7 @@ public class ChunkUploadServiceTests : IAsyncLifetime private readonly string _root = Path.Combine(Environment.CurrentDirectory, "managedcode-chunk-tests", Guid.NewGuid().ToString()); private ChunkUploadOptions _options = null!; - public Task InitializeAsync() + public ValueTask InitializeAsync() { Directory.CreateDirectory(_root); _options = new ChunkUploadOptions @@ -28,24 +28,25 @@ public Task InitializeAsync() SessionTtl = TimeSpan.FromMinutes(10), MaxActiveSessions = 4 }; - return Task.CompletedTask; + return ValueTask.CompletedTask; } - public Task DisposeAsync() + public ValueTask DisposeAsync() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } - return Task.CompletedTask; + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; } [Fact] public async Task CompleteAsync_WithCommit_ShouldMergeChunksAndUpload() { using var storage = CreateStorage(); - await storage.CreateContainerAsync(); + await storage.CreateContainerAsync(TestContext.Current.CancellationToken); var service = new ChunkUploadService(_options); var uploadId = Guid.NewGuid().ToString("N"); @@ -79,7 +80,7 @@ public async Task CompleteAsync_WithCommit_ShouldMergeChunksAndUpload() TotalChunks = totalChunks, FileSize = payload.Length } - }, default); + }, TestContext.Current.CancellationToken); appendResult.IsSuccess.ShouldBeTrue(); } @@ -89,25 +90,25 @@ public async Task CompleteAsync_WithCommit_ShouldMergeChunksAndUpload() UploadId = uploadId, FileName = fileName, CommitToStorage = true - }, storage, default); + }, storage, TestContext.Current.CancellationToken); completeResult.IsSuccess.ShouldBeTrue(); var completion = completeResult.Value ?? throw new InvalidOperationException("Completion result is null"); completion.Checksum.ShouldBe(checksum); completion.Metadata.ShouldNotBeNull(); - var metadata = await storage.GetBlobMetadataAsync(fileName); + var metadata = await storage.GetBlobMetadataAsync(fileName, TestContext.Current.CancellationToken); metadata.IsSuccess.ShouldBeTrue(); (metadata.Value ?? throw new InvalidOperationException("Metadata value is null")).Length.ShouldBe((ulong)payload.Length); - var download = await storage.DownloadAsync(fileName); + var download = await storage.DownloadAsync(fileName, TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); var downloadedFile = download.Value ?? throw new InvalidOperationException("Download returned null file"); using var ms = new MemoryStream(); - await downloadedFile.FileStream.CopyToAsync(ms); + await downloadedFile.FileStream.CopyToAsync(ms, TestContext.Current.CancellationToken); ms.ToArray().ShouldBe(payload); - var repeat = await service.CompleteAsync(new ChunkUploadCompleteRequest { UploadId = uploadId }, storage, default); + var repeat = await service.CompleteAsync(new ChunkUploadCompleteRequest { UploadId = uploadId }, storage, TestContext.Current.CancellationToken); repeat.IsSuccess.ShouldBeFalse(); } @@ -131,7 +132,7 @@ public async Task Abort_ShouldRemoveSessionArtifacts() ChunkSize = chunkBytes.Length, TotalChunks = 1 } - }, default); + }, TestContext.Current.CancellationToken); append.IsSuccess.ShouldBeTrue(); var workingDirectory = Path.Combine(_options.TempPath, uploadId); @@ -183,7 +184,7 @@ async Task Append(string uploadId) public async Task CompleteAsync_WithLargeChunkSize_ShouldPreserveChecksum() { using var storage = CreateStorage(); - await storage.CreateContainerAsync(); + await storage.CreateContainerAsync(TestContext.Current.CancellationToken); var service = new ChunkUploadService(_options); var uploadId = Guid.NewGuid().ToString("N"); @@ -208,7 +209,7 @@ public async Task CompleteAsync_WithLargeChunkSize_ShouldPreserveChecksum() TotalChunks = 1, FileSize = payload.Length } - }, default); + }, TestContext.Current.CancellationToken); appendResult.IsSuccess.ShouldBeTrue(); @@ -217,7 +218,7 @@ public async Task CompleteAsync_WithLargeChunkSize_ShouldPreserveChecksum() UploadId = uploadId, FileName = fileName, CommitToStorage = true - }, storage, default); + }, storage, TestContext.Current.CancellationToken); complete.IsSuccess.ShouldBeTrue(); (complete.Value ?? throw new InvalidOperationException("Completion result is null")).Checksum.ShouldBe(checksum); diff --git a/Tests/ManagedCode.Storage.Tests/StorageTestRunner.cs b/Tests/ManagedCode.Storage.Tests/StorageTestRunner.cs new file mode 100644 index 0000000..3d7731e --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/StorageTestRunner.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using Xunit.Runner.InProc.SystemConsole; + +namespace ManagedCode.Storage.Tests; + +internal static class StorageTestRunner +{ + public static async Task Main(string[] args) => await ConsoleRunner.Run(args); +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsLegalHoldContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsLegalHoldContractsTests.cs new file mode 100644 index 0000000..f0844a7 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsLegalHoldContractsTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Amazon.S3; +using Amazon.S3.Model; +using ManagedCode.Storage.Aws; +using ManagedCode.Storage.Aws.Options; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.Storages.Registration; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.AWS; + +public sealed class AwsLegalHoldContractsTests(AwsRegistrationHost host) : IClassFixture +{ + [Fact] + public async Task ObjectLockLegalHold_CanBeSetReadAndClearedThroughTheRealS3Sdk() + { + var bucket = "hold-" + Guid.NewGuid().ToString("N"); + var configuration = new AmazonS3Config { ServiceURL = host.Endpoint, ForcePathStyle = true }; + using var sdk = new AmazonS3Client("localkey", "localsecret", configuration); + var token = TestContext.Current.CancellationToken; + await sdk.PutBucketAsync(new PutBucketRequest { BucketName = bucket, ObjectLockEnabledForBucket = true }, token); + using var storage = new AWSStorage(new AWSStorageOptions + { Bucket = bucket, PublicKey = "localkey", SecretKey = "localsecret", OriginalOptions = configuration }); + (await storage.UploadAsync("held content", new UploadOptions { FileName = "held.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(true, "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("held.txt", token)).Value.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(false, "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("held.txt", token)).Value.ShouldBeFalse(); + (await storage.SetLegalHoldAsync(true, "missing.txt", token)).IsFailed.ShouldBeTrue(); + var metadata = await sdk.GetObjectMetadataAsync(bucket, "held.txt", token); + await sdk.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = "held.txt", VersionId = metadata.VersionId }, token); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs new file mode 100644 index 0000000..f667620 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using DotNet.Testcontainers.Containers; +using ManagedCode.Storage.Aws; +using ManagedCode.Storage.Tests.Common; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Testcontainers.LocalStack; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.AWS; + +public sealed class AwsMultipartStreamContractsTests : BaseContainer +{ + protected override LocalStackContainer Build() => AwsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => AWSConfigurator.ConfigureServices(Container.GetConnectionString()); + + private AmazonS3Client CreateClient() => new(new BasicAWSCredentials("localkey", "localsecret"), + new AmazonS3Config { ServiceURL = Container.GetConnectionString(), ForcePathStyle = true }); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task MultipartWrites_RoundTripBytesAcrossPartBoundaries(int constructor) + { + using var client = CreateClient(); + var token = TestContext.Current.CancellationToken; + var bucket = "stream-contract-" + Guid.NewGuid().ToString("N"); + await client.PutBucketAsync(bucket, token); + var content = new byte[5 * 1024 * 1024 + 257]; + new Random(19).NextBytes(content); + var key = "multipart.bin"; + var uri = new Uri($"s3://{bucket}/{key}"); + var stream = constructor switch + { + 0 => new BlobStream(client, bucket, key), + 1 => new BlobStream(client, uri), + _ => new BlobStream(client, uri.ToString()) + }; + stream.CanRead.ShouldBeFalse(); + stream.CanSeek.ShouldBeFalse(); + stream.CanWrite.ShouldBeTrue(); + stream.SetLength(content.Length); + stream.Write(content, 0, 5 * 1024 * 1024); + stream.Flush(); + stream.Write(content, 5 * 1024 * 1024, 257); + stream.Write(content, 0, 0); + stream.Length.ShouldBe(content.Length); + stream.Position.ShouldBe(content.Length); + stream.Dispose(); + stream.Dispose(); + stream.CanWrite.ShouldBeFalse(); + using var downloaded = await client.GetObjectAsync(bucket, key, token); + downloaded.ContentLength.ShouldBe(content.Length); + (await SHA256.HashDataAsync(downloaded.ResponseStream, token)).ShouldBe(SHA256.HashData(content)); + var pending = await client.ListMultipartUploadsAsync(new ListMultipartUploadsRequest { BucketName = bucket }, token); + (pending.MultipartUploads?.Count ?? 0).ShouldBe(0); + } + + [Fact] + public async Task EmptyStream_CreatesEmptyObjectWithoutLeavingMultipartUpload() + { + using var client = CreateClient(); + var token = TestContext.Current.CancellationToken; + var bucket = "empty-contract-" + Guid.NewGuid().ToString("N"); + await client.PutBucketAsync(bucket, token); + using (var stream = new BlobStream(client, bucket, "empty")) + stream.Write(Array.Empty(), 0, 0); + using var downloaded = await client.GetObjectAsync(bucket, "empty", token); + downloaded.ContentLength.ShouldBe(0); + var pending = await client.ListMultipartUploadsAsync(new ListMultipartUploadsRequest { BucketName = bucket }, token); + (pending.MultipartUploads?.Count ?? 0).ShouldBe(0); + } + + [Fact] + public async Task InvalidAndDisposedWrites_RejectWithoutChangingStoredContent() + { + using var client = CreateClient(); + var token = TestContext.Current.CancellationToken; + var bucket = "validation-contract-" + Guid.NewGuid().ToString("N"); + await client.PutBucketAsync(bucket, token); + var stream = new BlobStream(client, bucket, "file"); + Should.Throw(() => stream.Write(null!, 0, 1)); + Should.Throw(() => stream.Write(new byte[2], -1, 1)); + Should.Throw(() => stream.Write(new byte[2], 0, -1)); + Should.Throw(() => stream.Write(new byte[2], 1, 2)); + Should.Throw(() => stream.Read(new byte[1], 0, 1)); + Should.Throw(() => stream.Seek(0, SeekOrigin.Begin)); + Should.Throw(() => stream.Position = 0); + stream.Write(new byte[] { 7, 8 }, 0, 2); + stream.Dispose(); + Should.Throw(() => stream.Write(new byte[] { 9 }, 0, 1)); + using var downloaded = await client.GetObjectAsync(bucket, "file", token); + downloaded.ContentLength.ShouldBe(2); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/BlobTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/BlobTests.cs index a93ad4e..c5afb77 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/BlobTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/BlobTests.cs @@ -18,8 +18,7 @@ public async Task GetBlobListAsync_WithoutOptions() var fileList = await UploadTestFileListAsync(); // Act - var result = await Storage.GetBlobMetadataListAsync() - .ToListAsync(); + var result = await Storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert result.Count @@ -30,7 +29,7 @@ public async Task GetBlobListAsync_WithoutOptions() var file = result.FirstOrDefault(f => f.Name == item.Name); file.ShouldNotBeNull(); - await Storage.DeleteAsync(item.Name); + await Storage.DeleteAsync(item.Name, TestContext.Current.CancellationToken); } } @@ -41,7 +40,7 @@ public virtual async Task GetBlobMetadataAsync_ShouldBeTrue() var fileInfo = await UploadTestFileAsync(); // Act - var result = await Storage.GetBlobMetadataAsync(fileInfo.Name); + var result = await Storage.GetBlobMetadataAsync(fileInfo.Name, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -51,7 +50,7 @@ public virtual async Task GetBlobMetadataAsync_ShouldBeTrue() result.Value!.Name .ShouldBe(fileInfo.Name); - await Storage.DeleteAsync(fileInfo.Name); + await Storage.DeleteAsync(fileInfo.Name, TestContext.Current.CancellationToken); } [Fact] @@ -61,7 +60,7 @@ public async Task DeleteAsync_WithoutOptions_ShouldTrue() var file = await UploadTestFileAsync(); // Act - var result = await Storage.DeleteAsync(file.Name); + var result = await Storage.DeleteAsync(file.Name, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -78,7 +77,7 @@ public async Task DeleteAsync_WithoutOptions_IfFileDontExist_ShouldFalse() .ToString(); // Act - var result = await Storage.DeleteAsync(blob); + var result = await Storage.DeleteAsync(blob, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -96,7 +95,7 @@ public async Task DeleteAsync_WithOptions_FromDirectory_ShouldTrue() DeleteOptions options = new() { FileName = file.Name, Directory = directory }; // Act - var result = await Storage.DeleteAsync(options); + var result = await Storage.DeleteAsync(options, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -118,7 +117,7 @@ public async Task DeleteAsync_WithOptions_IfFileDontExist_FromDirectory_ShouldFa }; // Act - var result = await Storage.DeleteAsync(options); + var result = await Storage.DeleteAsync(options, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -134,7 +133,7 @@ public async Task ExistsAsync_WithoutOptions_ShouldBeTrue() var fileInfo = await UploadTestFileAsync(); // Act - var result = await Storage.ExistsAsync(fileInfo.Name); + var result = await Storage.ExistsAsync(fileInfo.Name, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -142,7 +141,7 @@ public async Task ExistsAsync_WithoutOptions_ShouldBeTrue() result.Value .ShouldBeTrue(); - await Storage.DeleteAsync(fileInfo.Name); + await Storage.DeleteAsync(fileInfo.Name, TestContext.Current.CancellationToken); } [Fact] @@ -154,7 +153,7 @@ public async Task ExistsAsync_WithOptions_InDirectory_ShouldBeTrue() ExistOptions options = new() { FileName = fileInfo.Name, Directory = directory }; // Act - var result = await Storage.ExistsAsync(options); + var result = await Storage.ExistsAsync(options, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -162,7 +161,7 @@ public async Task ExistsAsync_WithOptions_InDirectory_ShouldBeTrue() result.Value .ShouldBeTrue(); - await Storage.DeleteAsync(fileInfo.Name); + await Storage.DeleteAsync(fileInfo.Name, TestContext.Current.CancellationToken); } [Fact] @@ -170,7 +169,7 @@ public async Task ExistsAsync_IfFileDontExist_WithoutOptions_ShouldBeFalse() { // Act var result = await Storage.ExistsAsync(Guid.NewGuid() - .ToString()); + .ToString(), TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -188,7 +187,7 @@ public async Task ExistsAsync_IfFileFileExistInAnotherDirectory_WithOptions_Shou ExistOptions options = new() { FileName = fileInfo.Name, Directory = "another-directory" }; // Act - var result = await Storage.ExistsAsync(options); + var result = await Storage.ExistsAsync(options, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -196,6 +195,6 @@ public async Task ExistsAsync_IfFileFileExistInAnotherDirectory_WithOptions_Shou result.Value .ShouldBeFalse(); - await Storage.DeleteAsync(fileInfo.Name); + await Storage.DeleteAsync(fileInfo.Name, TestContext.Current.CancellationToken); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/ContainerTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/ContainerTests.cs index 3e0e4ff..e3851cc 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/ContainerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/ContainerTests.cs @@ -13,7 +13,7 @@ public abstract class ContainerTests : BaseContainer where T : IContainer [Fact] public async Task CreateContainer_ShouldBeSuccess() { - var container = await Storage.CreateContainerAsync(); + var container = await Storage.CreateContainerAsync(TestContext.Current.CancellationToken); container.IsSuccess .ShouldBeTrue(); } @@ -27,11 +27,11 @@ public async Task CreateContainerAsync() [Fact] public async Task RemoveContainer_ShouldBeSuccess() { - var createResult = await Storage.CreateContainerAsync(); + var createResult = await Storage.CreateContainerAsync(TestContext.Current.CancellationToken); createResult.IsSuccess .ShouldBeTrue(); - var result = await Storage.RemoveContainerAsync(); + var result = await Storage.RemoveContainerAsync(TestContext.Current.CancellationToken); result.IsSuccess .ShouldBeTrue(result.Problem?.Detail ?? "Failed without details"); @@ -44,8 +44,7 @@ public async Task GetFileListAsyncTest() await UploadTestFileAsync(); await UploadTestFileAsync(); - var files = await Storage.GetBlobMetadataListAsync() - .ToListAsync(); + var files = await Storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); files.Count .ShouldBeGreaterThanOrEqualTo(3); } @@ -58,9 +57,8 @@ public async Task DeleteDirectory_ShouldBeSuccess() await UploadTestFileListAsync(directory, 3); // Act - var result = await Storage.DeleteDirectoryAsync(directory); - var blobs = await Storage.GetBlobMetadataListAsync(directory) - .ToListAsync(); + var result = await Storage.DeleteDirectoryAsync(directory, TestContext.Current.CancellationToken); + var blobs = await Storage.GetBlobMetadataListAsync(directory, TestContext.Current.CancellationToken).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert result.IsSuccess diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/DownloadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/DownloadTests.cs index 082878c..42cbd95 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/DownloadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/DownloadTests.cs @@ -15,7 +15,7 @@ public async Task DownloadAsync_WithoutOptions_AsLocalFile() var fileInfo = await UploadTestFileAsync(); // Act - var result = await Storage.DownloadAsync(fileInfo.Name); + var result = await Storage.DownloadAsync(fileInfo.Name, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -24,6 +24,6 @@ public async Task DownloadAsync_WithoutOptions_AsLocalFile() .Length .ShouldBe(fileInfo.Length); - await Storage.DeleteAsync(fileInfo.Name); + await Storage.DeleteAsync(fileInfo.Name, TestContext.Current.CancellationToken); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/RemoteVfsRangeContracts.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/RemoteVfsRangeContracts.cs new file mode 100644 index 0000000..3d7a816 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/RemoteVfsRangeContracts.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using DotNet.Testcontainers.Containers; +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Abstracts; + +public abstract class RemoteVfsRangeContracts : BaseContainer where T : IContainer +{ + [Fact] + public async Task RangeReads_RespectOffsetsShortReadsAndEofOnActualProviderStreams() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddVirtualFileSystem(Storage, options => options.DefaultContainer = string.Empty); + await using var provider = services.BuildServiceProvider(); + var vfs = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + var file = await vfs.GetFileAsync(new VfsPath("/range-contract.bin"), token); + var content = new byte[131079]; + new Random(37).NextBytes(content); + await file.WriteAllBytesAsync(content, cancellationToken: token); + foreach (var (offset, count) in new[] { (0, 19), (3, 17), (81921, 21), (131075, 20), (131100, 4), (0, 0) }) + (await file.ReadRangeAsync(offset, count, token)).ShouldBe(content.Skip(offset).Take(count).ToArray()); + await Should.ThrowAsync(async () => await file.ReadRangeAsync(-1, 1, token)); + await Should.ThrowAsync(async () => await file.ReadRangeAsync(0, -1, token)); + (await file.ReadAllBytesAsync(token)).ShouldBe(content); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/StorageClientTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/StorageClientTests.cs index a78c1de..e2171d1 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/StorageClientTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/StorageClientTests.cs @@ -42,7 +42,7 @@ public async Task DownloadFile_Successful() var fileName = "testFile.txt"; var apiUrl = "https://loader.com"; - var result = await _storageClient.DownloadFile(fileName, apiUrl); + var result = await _storageClient.DownloadFile(fileName, apiUrl, cancellationToken: TestContext.Current.CancellationToken); result.IsSuccess .ShouldBeTrue(); @@ -56,7 +56,7 @@ public async Task DownloadFile_HttpRequestException() var fileName = "testFile.txt"; var apiUrl = "https://invalid-url.com"; - var result = await _storageClient.DownloadFile(fileName, apiUrl); + var result = await _storageClient.DownloadFile(fileName, apiUrl, cancellationToken: TestContext.Current.CancellationToken); result.IsSuccess .ShouldBeFalse(); @@ -69,7 +69,7 @@ public async Task DownloadFile_OtherException() var fileName = "testFile.txt"; var apiUrl = "https://loader.com"; - var result = await _storageClient.DownloadFile(fileName, apiUrl + "/invalid-endpoint"); + var result = await _storageClient.DownloadFile(fileName, apiUrl + "/invalid-endpoint", cancellationToken: TestContext.Current.CancellationToken); result.IsSuccess .ShouldBeFalse(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UnavailableStorageContracts.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UnavailableStorageContracts.cs new file mode 100644 index 0000000..cc175ae --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UnavailableStorageContracts.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Containers; +using ManagedCode.Storage.Tests.Common; +using Shouldly; +using Xunit; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.Storage.Tests.Storages.Abstracts; + +public abstract class UnavailableStorageContracts : BaseContainer where T : IContainer +{ + [Fact] + public async Task ServiceOutage_ReturnsFailuresForStorageOperations() + { + var token = TestContext.Current.CancellationToken; + (await Storage.UploadAsync("seed", cancellationToken: token)).IsSuccess.ShouldBeTrue(); + await using var freshProvider = ConfigureServices(); + var freshStorage = freshProvider.GetRequiredService(); + await Container.StopAsync(token); + + async Task ExpectFailureAsync(Func> operation) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + (await operation(timeout.Token)).ShouldBeFalse(); + } + + await ExpectFailureAsync(async ct => (await Storage.UploadAsync("cannot upload", cancellationToken: ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.DownloadAsync("missing", ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.GetStreamAsync("missing", ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.GetBlobMetadataAsync("missing", ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.ExistsAsync("missing", ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.DeleteAsync("missing", ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await freshStorage.CreateContainerAsync(ct)).IsSuccess); + await ExpectFailureAsync(async ct => (await Storage.RemoveContainerAsync(ct)).IsSuccess); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UploadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UploadTests.cs index cb661aa..509bace 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UploadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Abstracts/UploadTests.cs @@ -24,13 +24,13 @@ public async Task UploadAsync_AsText_WithoutOptions() var uploadContent = FileHelper.GenerateRandomFileContent(); // Act - var result = await Storage.UploadAsync(uploadContent); + var result = await Storage.UploadAsync(uploadContent, TestContext.Current.CancellationToken); // Assert result.IsSuccess .ShouldBeTrue(); - var downloadedResult = await Storage.DownloadAsync(result.Value!.Name); + var downloadedResult = await Storage.DownloadAsync(result.Value!.Name, TestContext.Current.CancellationToken); downloadedResult.IsSuccess .ShouldBeTrue(); } @@ -44,13 +44,13 @@ public async Task UploadAsync_AsStream_WithoutOptions() using var stream = new MemoryStream(byteArray); // Act - var result = await Storage.UploadAsync(stream); + var result = await Storage.UploadAsync(stream, TestContext.Current.CancellationToken); // Assert result.IsSuccess .ShouldBeTrue(); - var downloadedResult = await Storage.DownloadAsync(result.Value!.Name); + var downloadedResult = await Storage.DownloadAsync(result.Value!.Name, TestContext.Current.CancellationToken); downloadedResult.IsSuccess .ShouldBeTrue(); } @@ -60,7 +60,7 @@ public async Task StreamUploadAsyncTest() { var file = await GetTestFileAsync(); await using var stream = file.OpenRead(); - var uploadResult = await Storage.UploadAsync(stream); + var uploadResult = await Storage.UploadAsync(stream, TestContext.Current.CancellationToken); uploadResult.IsSuccess .ShouldBeTrue(); } @@ -69,8 +69,8 @@ public async Task StreamUploadAsyncTest() public async Task ArrayUploadAsyncTest() { var file = await GetTestFileAsync(); - var bytes = await File.ReadAllBytesAsync(file.FullName); - var uploadResult = await Storage.UploadAsync(bytes); + var bytes = await File.ReadAllBytesAsync(file.FullName, TestContext.Current.CancellationToken); + var uploadResult = await Storage.UploadAsync(bytes, TestContext.Current.CancellationToken); uploadResult.IsSuccess .ShouldBeTrue(); } @@ -79,8 +79,8 @@ public async Task ArrayUploadAsyncTest() public async Task StringUploadAsyncTest() { var file = await GetTestFileAsync(); - var text = await File.ReadAllTextAsync(file.FullName); - var uploadResult = await Storage.UploadAsync(text); + var text = await File.ReadAllTextAsync(file.FullName, TestContext.Current.CancellationToken); + var uploadResult = await Storage.UploadAsync(text, TestContext.Current.CancellationToken); uploadResult.IsSuccess .ShouldBeTrue(); } @@ -89,11 +89,11 @@ public async Task StringUploadAsyncTest() public async Task FileInfoUploadAsyncTest() { var file = await GetTestFileAsync(); - var uploadResult = await Storage.UploadAsync(file); + var uploadResult = await Storage.UploadAsync(file, TestContext.Current.CancellationToken); uploadResult.IsSuccess .ShouldBeTrue(); - var downloadResult = await Storage.DownloadAsync(uploadResult.Value!.Name); + var downloadResult = await Storage.DownloadAsync(uploadResult.Value!.Name, TestContext.Current.CancellationToken); downloadResult.IsSuccess .ShouldBeTrue(); } @@ -110,8 +110,8 @@ public async Task UploadAsync_AsStream_WithOptions_ToDirectory_SpecifyingFileNam using var stream = new MemoryStream(byteArray); // Act - var result = await Storage.UploadAsync(stream, new UploadOptions { FileName = fileName, Directory = directory }); - var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }); + var result = await Storage.UploadAsync(stream, new UploadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); + var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -131,8 +131,8 @@ public async Task UploadAsync_AsArray_WithOptions_ToDirectory_SpecifyingFileName var byteArray = Encoding.ASCII.GetBytes(uploadContent); // Act - var result = await Storage.UploadAsync(byteArray, new UploadOptions { FileName = fileName, Directory = directory }); - var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }); + var result = await Storage.UploadAsync(byteArray, new UploadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); + var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -140,7 +140,7 @@ public async Task UploadAsync_AsArray_WithOptions_ToDirectory_SpecifyingFileName downloadedResult.IsSuccess .ShouldBeTrue(); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Fact] @@ -152,8 +152,8 @@ public async Task UploadAsync_AsText_WithOptions_ToDirectory_SpecifyingFileName( var fileName = FileHelper.GenerateRandomFileName(); // Act - var result = await Storage.UploadAsync(uploadContent, new UploadOptions { FileName = fileName, Directory = directory }); - var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }); + var result = await Storage.UploadAsync(uploadContent, new UploadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); + var downloadedResult = await Storage.DownloadAsync(new DownloadOptions { FileName = fileName, Directory = directory }, TestContext.Current.CancellationToken); // Assert result.IsSuccess @@ -161,7 +161,7 @@ public async Task UploadAsync_AsText_WithOptions_ToDirectory_SpecifyingFileName( downloadedResult.IsSuccess .ShouldBeTrue(); - await Storage.DeleteAsync(fileName); + await Storage.DeleteAsync(fileName, TestContext.Current.CancellationToken); } [Theory] @@ -177,7 +177,7 @@ public virtual async Task UploadAsync_LargeStream_ShouldRoundTrip(int gigabytes) var containerResult = await Storage.CreateContainerAsync(CancellationToken.None); containerResult.IsSuccess.ShouldBeTrue(); - await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin"); + await using var localFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin", cancellationToken: TestContext.Current.CancellationToken); var expectedCrc = LargeFileTestHelper.CalculateFileCrc(localFile.FilePath); var fileName = Path.GetFileName(localFile.FilePath); @@ -273,7 +273,7 @@ public virtual async Task UploadAsync_WithCancellationToken_BigFile_ShouldCancel { Thread.Sleep(50); cts.Cancel(); - }); + }, TestContext.Current.CancellationToken); var uploadTask = Storage.UploadAsync(stream, cancellationToken: cts.Token); await Task.WhenAll(uploadTask, cancellationTask); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs index 281b0c4..f4b28bd 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs @@ -39,7 +39,7 @@ public async Task ReadStreamWithStreamReader_WhenFileExists_ReturnData() UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; await using var localFileStream = localFile.FileInfo.OpenRead(); - var result = await storage.UploadAsync(localFileStream, options); + var result = await storage.UploadAsync(localFileStream, options, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var uploaded = result.Value ?? throw new InvalidOperationException("Upload did not return metadata"); @@ -47,12 +47,12 @@ public async Task ReadStreamWithStreamReader_WhenFileExists_ReturnData() // Act using var streamReader = new StreamReader(blobStream); - var content = await streamReader.ReadToEndAsync(); + var content = await streamReader.ReadToEndAsync(TestContext.Current.CancellationToken); // Assert await using var fileStream = localFile.FileInfo.OpenRead(); using var fileReader = new StreamReader(fileStream); - var fileContent = await fileReader.ReadToEndAsync(); + var fileContent = await fileReader.ReadToEndAsync(TestContext.Current.CancellationToken); content.ShouldNotBeNullOrEmpty(); fileContent.ShouldNotBeNullOrEmpty(); content.ShouldBe(fileContent); @@ -70,7 +70,7 @@ public async Task ReadStream_WhenFileExists_ReturnData() UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; await using var fileStream = localFile.FileInfo.OpenRead(); - var result = await storage.UploadAsync(fileStream, options); + var result = await storage.UploadAsync(fileStream, options, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var uploaded = result.Value ?? throw new InvalidOperationException("Upload did not return metadata"); @@ -81,8 +81,8 @@ public async Task ReadStream_WhenFileExists_ReturnData() var chunk2 = new byte[chunkSize]; // Act - var bytesReadForChunk1 = await blobStream.ReadAsync(chunk1, 0, chunkSize); - var bytesReadForChunk2 = await blobStream.ReadAsync(chunk2, 0, chunkSize); + var bytesReadForChunk1 = await blobStream.ReadAsync(chunk1, 0, chunkSize, TestContext.Current.CancellationToken); + var bytesReadForChunk2 = await blobStream.ReadAsync(chunk2, 0, chunkSize, TestContext.Current.CancellationToken); // Assert bytesReadForChunk1.ShouldBe(chunkSize); @@ -101,14 +101,14 @@ public async Task ReadStream_WhenFileDoesNotExists_ReturnNoData() // Arrange var directory = "test-directory"; var storage = (IAzureStorage)Storage; - await storage.CreateContainerAsync(); + await storage.CreateContainerAsync(TestContext.Current.CancellationToken); var fullFileName = $"{directory}/{Guid.NewGuid()}.txt"; await using var blobStream = storage.GetBlobStream(fullFileName); var chunk = new byte[4]; // Act - var bytesRead = await blobStream.ReadAsync(chunk, 0, 4); + var bytesRead = await blobStream.ReadAsync(chunk, 0, 4, TestContext.Current.CancellationToken); // Assert bytesRead.ShouldBe(0); @@ -129,25 +129,25 @@ public async Task WriteStreamWithStreamWriter_SaveData() var storage = (IAzureStorage)Storage; - await storage.CreateContainerAsync(); + await storage.CreateContainerAsync(TestContext.Current.CancellationToken); // Act await using (var blobStream = storage.GetBlobStream(fullFileName)) { await using (var localFileStream = localFile.FileStream) { - await localFileStream.CopyToAsync(blobStream); + await localFileStream.CopyToAsync(blobStream, TestContext.Current.CancellationToken); } } // Assert - var fileResult = await storage.DownloadAsync(fullFileName); + var fileResult = await storage.DownloadAsync(fullFileName, TestContext.Current.CancellationToken); fileResult.IsSuccess .ShouldBeTrue(); var downloaded = fileResult.Value ?? throw new InvalidOperationException("Download result is null"); await using var fileStream = downloaded.FileStream; using var streamReader = new StreamReader(fileStream); - var fileContent = await streamReader.ReadLineAsync(); + var fileContent = await streamReader.ReadLineAsync(TestContext.Current.CancellationToken); fileContent.ShouldNotBeNullOrEmpty(); } @@ -163,7 +163,7 @@ public async Task Seek_WhenFileExists_ReturnData() UploadOptions options = new() { FileName = localFile.Name, Directory = directory }; await using var localFileStream = localFile.FileInfo.OpenRead(); - var result = await storage.UploadAsync(localFileStream, options); + var result = await storage.UploadAsync(localFileStream, options, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var uploaded = result.Value ?? throw new InvalidOperationException("Upload did not return metadata"); @@ -173,13 +173,13 @@ public async Task Seek_WhenFileExists_ReturnData() var seekInPosition = fileSizeInBytes / 2; blobStream.Seek(seekInPosition, SeekOrigin.Current); var buffer = new byte[seekInPosition]; - var bytesRead = await blobStream.ReadAsync(buffer); + var bytesRead = await blobStream.ReadAsync(buffer, TestContext.Current.CancellationToken); // Assert bytesRead.ShouldBe(seekInPosition); await using var fileStream = localFile.FileInfo.OpenRead(); using var fileReader = new StreamReader(fileStream); - var fileContent = await fileReader.ReadToEndAsync(); + var fileContent = await fileReader.ReadToEndAsync(TestContext.Current.CancellationToken); var content = Encoding.UTF8.GetString(buffer); content.ShouldNotBeNullOrEmpty(); var trimmedFileContent = fileContent.Remove(0, seekInPosition); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobTests.cs index 934a116..a0f761f 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobTests.cs @@ -28,7 +28,7 @@ public async Task GetBlobMetadataAsync_WhenBlobIsMissing_ShouldNotLogException() }, logger); - var result = await storage.GetBlobMetadataAsync($"missing-{Guid.NewGuid():N}.txt"); + var result = await storage.GetBlobMetadataAsync($"missing-{Guid.NewGuid():N}.txt", TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeFalse(); logger.Exceptions.ShouldBeEmpty(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs index 1062e03..944ca16 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs @@ -22,8 +22,8 @@ public sealed class AzureMetadataEncodingTests : IAsyncLifetime private readonly AzuriteContainer _container = new AzuriteBuilder(ContainerImages.Azurite) .WithCommand("--skipApiVersionCheck").Build(); - public Task InitializeAsync() => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + public ValueTask InitializeAsync() => new(_container.StartAsync()); + public ValueTask DisposeAsync() => _container.DisposeAsync(); [Fact] public async Task PortableUpload_PreservesUnicodeMetadataAcrossReadsAndDownload() @@ -36,20 +36,20 @@ public async Task PortableUpload_PreservesUnicodeMetadataAcrossReadsAndDownload( FileName = "material.txt", MimeType = "text/plain", Metadata = metadata - }); + }, TestContext.Current.CancellationToken); upload.IsSuccess.ShouldBeTrue(); AssertMetadata(upload.Value!.Metadata!, metadata); - var stored = await storage.GetBlobMetadataAsync("material.txt"); + var stored = await storage.GetBlobMetadataAsync("material.txt", TestContext.Current.CancellationToken); stored.IsSuccess.ShouldBeTrue(); AssertMetadata(stored.Value!.Metadata!, metadata); - var listed = await storage.GetBlobMetadataListAsync().ToListAsync(); + var listed = await storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken).ToListAsync(cancellationToken: TestContext.Current.CancellationToken); AssertMetadata(listed.Single().Metadata!, metadata); - var downloaded = await storage.DownloadAsync("material.txt"); + var downloaded = await storage.DownloadAsync("material.txt", TestContext.Current.CancellationToken); downloaded.IsSuccess.ShouldBeTrue(); using var file = downloaded.Value!; AssertMetadata(file.BlobMetadata!.Metadata!, metadata); - (await File.ReadAllTextAsync(file.FileInfo.FullName)).ShouldBe("unchanged material"); - var native = await NativeContainer(storage).GetBlobClient("material.txt").GetPropertiesAsync(); + (await File.ReadAllTextAsync(file.FileInfo.FullName, TestContext.Current.CancellationToken)).ShouldBe("unchanged material"); + var native = await NativeContainer(storage).GetBlobClient("material.txt").GetPropertiesAsync(cancellationToken: TestContext.Current.CancellationToken); native.Value.Metadata.Values.All(value => value.All(c => c is >= ' ' and <= '~')).ShouldBeTrue(); } @@ -59,31 +59,29 @@ public async Task ObjectAndContainerMetadata_PreserveValuesAndConditionalRevisio using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); var metadata = UnicodeMetadata(); - await objects.CreatePrivateContainerAsync(metadata); - AssertMetadata((await objects.GetContainerInfoAsync()).Metadata, metadata); + await objects.CreatePrivateContainerAsync(metadata, TestContext.Current.CancellationToken); + AssertMetadata((await objects.GetContainerInfoAsync(TestContext.Current.CancellationToken)).Metadata, metadata); var replacement = new Dictionary { ["owner"] = "Компанія" }; - await objects.SetContainerMetadataAsync(replacement); - AssertMetadata((await objects.GetContainerInfoAsync()).Metadata, replacement); + await objects.SetContainerMetadataAsync(replacement, TestContext.Current.CancellationToken); + AssertMetadata((await objects.GetContainerInfoAsync(TestContext.Current.CancellationToken)).Metadata, replacement); using var content = Content("unchanged evidence"); - var first = await objects.WriteIfAbsentOrSameAsync("evidence", content, content.Length, - new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }); + var first = await objects.WriteIfAbsentOrSameAsync("evidence", content, content.Length, new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }, cancellationToken: TestContext.Current.CancellationToken); AssertMetadata(first.Info.Metadata, metadata); using var retry = Content("unchanged evidence"); - var reused = await objects.WriteIfAbsentOrSameAsync("evidence", retry, retry.Length, - new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }); + var reused = await objects.WriteIfAbsentOrSameAsync("evidence", retry, retry.Length, new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }, cancellationToken: TestContext.Current.CancellationToken); reused.ReusedExisting.ShouldBeTrue(); reused.Info.ETag.ShouldBe(first.Info.ETag); - AssertMetadata((await objects.ListObjectsAsync()).Items.Single().Metadata, metadata); - await objects.SetObjectMetadataAsync("evidence", replacement, first.Info.ETag); - var second = await objects.GetObjectInfoAsync("evidence"); + AssertMetadata((await objects.ListObjectsAsync(cancellationToken: TestContext.Current.CancellationToken)).Items.Single().Metadata, metadata); + await objects.SetObjectMetadataAsync("evidence", replacement, first.Info.ETag, TestContext.Current.CancellationToken); + var second = await objects.GetObjectInfoAsync("evidence", TestContext.Current.CancellationToken); AssertMetadata(second.Metadata, replacement); second.ETag.ShouldNotBe(first.Info.ETag); var stale = await Should.ThrowAsync(() => objects.SetObjectMetadataAsync("evidence", metadata, first.Info.ETag)); stale.IsConflict.ShouldBeTrue(); - await using var stream = await objects.OpenObjectReadAsync("evidence", new StorageReadOptions { IfMatch = second.ETag }); + await using var stream = await objects.OpenObjectReadAsync("evidence", new StorageReadOptions { IfMatch = second.ETag }, TestContext.Current.CancellationToken); using var reader = new StreamReader(stream); - (await reader.ReadToEndAsync()).ShouldBe("unchanged evidence"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("unchanged evidence"); } [Fact] @@ -91,22 +89,22 @@ public async Task MultipartAndMarkerCollision_PreserveCompleteLogicalMetadata() { using var storage = CreateStorage(); var objects = storage.RequireMultipartStorage(); - await objects.CreatePrivateContainerAsync(); + await objects.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var metadata = UnicodeMetadata(); var partId = Convert.ToBase64String(Encoding.UTF8.GetBytes("0001")); using var content = Content("multipart material"); - await objects.StagePartAsync("multipart", partId, content); - var committed = await objects.CommitPartsAsync("multipart", [partId], new StorageWriteOptions { Metadata = metadata }); + await objects.StagePartAsync("multipart", partId, content, TestContext.Current.CancellationToken); + var committed = await objects.CommitPartsAsync("multipart", [partId], new StorageWriteOptions { Metadata = metadata }, TestContext.Current.CancellationToken); AssertMetadata(committed.Metadata, metadata); - var native = await NativeContainer(storage).GetBlobClient("multipart").GetPropertiesAsync(); + var native = await NativeContainer(storage).GetBlobClient("multipart").GetPropertiesAsync(cancellationToken: TestContext.Current.CancellationToken); var marker = native.Value.Metadata.Single(); var collision = new Dictionary { [marker.Key] = marker.Value }; using var collidingContent = Content("caller marker"); - await objects.WriteObjectAsync("collision", collidingContent, new StorageWriteOptions { Metadata = collision }); - AssertMetadata((await objects.GetObjectInfoAsync("collision")).Metadata, collision); - await using var stream = await objects.OpenObjectReadAsync("multipart"); + await objects.WriteObjectAsync("collision", collidingContent, new StorageWriteOptions { Metadata = collision }, TestContext.Current.CancellationToken); + AssertMetadata((await objects.GetObjectInfoAsync("collision", TestContext.Current.CancellationToken)).Metadata, collision); + await using var stream = await objects.OpenObjectReadAsync("multipart", cancellationToken: TestContext.Current.CancellationToken); using var reader = new StreamReader(stream); - (await reader.ReadToEndAsync()).ShouldBe("multipart material"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("multipart material"); } [Theory] @@ -116,11 +114,11 @@ public async Task MalformedEnvelope_FailsWithoutReturningTransportMetadata(bool { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(); + await objects.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); using var content = Content("material"); - await objects.WriteObjectAsync("invalid", content, new StorageWriteOptions { Metadata = UnicodeMetadata() }); + await objects.WriteObjectAsync("invalid", content, new StorageWriteOptions { Metadata = UnicodeMetadata() }, TestContext.Current.CancellationToken); var blob = NativeContainer(storage).GetBlobClient("invalid"); - var encoded = (await blob.GetPropertiesAsync()).Value.Metadata.Single(); + var encoded = (await blob.GetPropertiesAsync(cancellationToken: TestContext.Current.CancellationToken)).Value.Metadata.Single(); var prefix = encoded.Value[..(encoded.Value.IndexOf(':') + 1)]; var invalid = new Dictionary { @@ -128,9 +126,9 @@ public async Task MalformedEnvelope_FailsWithoutReturningTransportMetadata(bool }; if (mixedMetadata) invalid["unexpected"] = "sibling"; - await blob.SetMetadataAsync(invalid); + await blob.SetMetadataAsync(invalid, cancellationToken: TestContext.Current.CancellationToken); await Should.ThrowAsync(() => objects.GetObjectInfoAsync("invalid")); - (await storage.GetBlobMetadataAsync("invalid")).IsFailed.ShouldBeTrue(); + (await storage.GetBlobMetadataAsync("invalid", TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); } [Fact] @@ -138,19 +136,18 @@ public async Task NativeAsciiMetadata_RemainsUnencodedAndUninterpreted() { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(); + await objects.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var metadata = new Dictionary { ["name"] = "%D0%BC.txt", ["literal"] = "utf8-json-base64:eyJmb28iOiJiYXIifQ==" }; using var nativeContent = Content("native bytes"); - await NativeContainer(storage).GetBlobClient("native").UploadAsync(nativeContent, - new global::Azure.Storage.Blobs.Models.BlobUploadOptions { Metadata = metadata }); - AssertMetadata((await objects.GetObjectInfoAsync("native")).Metadata, metadata); + await NativeContainer(storage).GetBlobClient("native").UploadAsync(nativeContent, new global::Azure.Storage.Blobs.Models.BlobUploadOptions { Metadata = metadata }, TestContext.Current.CancellationToken); + AssertMetadata((await objects.GetObjectInfoAsync("native", TestContext.Current.CancellationToken)).Metadata, metadata); using var content = Content("provider bytes"); - await objects.WriteObjectAsync("provider", content, new StorageWriteOptions { Metadata = metadata }); - var native = await NativeContainer(storage).GetBlobClient("provider").GetPropertiesAsync(); + await objects.WriteObjectAsync("provider", content, new StorageWriteOptions { Metadata = metadata }, TestContext.Current.CancellationToken); + var native = await NativeContainer(storage).GetBlobClient("provider").GetPropertiesAsync(cancellationToken: TestContext.Current.CancellationToken); AssertMetadata(native.Value.Metadata, metadata); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs index 06138bc..5e6ff98 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs @@ -27,32 +27,32 @@ public sealed class AzureObjectStorageTests : IAsyncLifetime private readonly AzuriteContainer _container = new AzuriteBuilder(ContainerImages.Azurite) .WithCommand("--skipApiVersionCheck").Build(); - public Task InitializeAsync() => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + public ValueTask InitializeAsync() => new(_container.StartAsync()); + public ValueTask DisposeAsync() => _container.DisposeAsync(); [Fact] public async Task ConditionalWritesAndReads_PreserveExactRevision() { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(new Dictionary { ["owner"] = "first" }); - (await objects.GetContainerInfoAsync()).IsPrivate.ShouldBeTrue(); + await objects.CreatePrivateContainerAsync(new Dictionary { ["owner"] = "first" }, TestContext.Current.CancellationToken); + (await objects.GetContainerInfoAsync(TestContext.Current.CancellationToken)).IsPrivate.ShouldBeTrue(); using var initial = Content("original"); - var first = await objects.WriteObjectAsync("file.txt", initial, new StorageWriteOptions { IfAbsent = true }); + var first = await objects.WriteObjectAsync("file.txt", initial, new StorageWriteOptions { IfAbsent = true }, TestContext.Current.CancellationToken); using var conflict = Content("conflict"); var duplicate = await Should.ThrowAsync(() => objects.WriteObjectAsync("file.txt", conflict, new StorageWriteOptions { IfAbsent = true })); duplicate.IsConflict.ShouldBeTrue(); using var replacement = Content("replacement"); - var second = await objects.WriteObjectAsync("file.txt", replacement, new StorageWriteOptions { IfMatch = first.ETag }); + var second = await objects.WriteObjectAsync("file.txt", replacement, new StorageWriteOptions { IfMatch = first.ETag }, TestContext.Current.CancellationToken); second.ETag.ShouldNotBe(first.ETag); var stale = await Should.ThrowAsync(() => objects.OpenObjectReadAsync("file.txt", new StorageReadOptions { IfMatch = first.ETag })); stale.IsConflict.ShouldBeTrue(); - await using var stream = await objects.OpenObjectReadAsync("file.txt", new StorageReadOptions { IfMatch = second.ETag, Offset = 2, Length = 4 }); + await using var stream = await objects.OpenObjectReadAsync("file.txt", new StorageReadOptions { IfMatch = second.ETag, Offset = 2, Length = 4 }, TestContext.Current.CancellationToken); using var reader = new StreamReader(stream); - (await reader.ReadToEndAsync()).ShouldBe("plac"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("plac"); using var rejected = Content("wrong"); (await Should.ThrowAsync(() => objects.WriteObjectAsync("file.txt", rejected, new StorageWriteOptions { IfMatch = first.ETag }))).IsConflict.ShouldBeTrue(); - (await objects.GetObjectInfoAsync("file.txt")).Length.ShouldBe(11); + (await objects.GetObjectInfoAsync("file.txt", TestContext.Current.CancellationToken)).Length.ShouldBe(11); } [Fact] @@ -60,21 +60,21 @@ public async Task MultipartUpload_ResumesAndCommitsInRequestedOrder() { using var storage = CreateStorage(); var multipart = storage.RequireMultipartStorage(); - await multipart.CreatePrivateContainerAsync(); + await multipart.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var firstId = Convert.ToBase64String(Encoding.UTF8.GetBytes("0001")); var secondId = Convert.ToBase64String(Encoding.UTF8.GetBytes("0002")); using var first = Content("first"); - await multipart.StagePartAsync("archive", firstId, first); + await multipart.StagePartAsync("archive", firstId, first, TestContext.Current.CancellationToken); using var resumed = CreateStorage(storage.ContainerUri.Segments[^1]); var resumedParts = resumed.RequireMultipartStorage(); using var second = Content("second"); - await resumedParts.StagePartAsync("archive", secondId, second); - var committed = await resumedParts.CommitPartsAsync("archive", [secondId, firstId], new StorageWriteOptions { IfAbsent = true, ContentType = "application/zip" }); + await resumedParts.StagePartAsync("archive", secondId, second, TestContext.Current.CancellationToken); + var committed = await resumedParts.CommitPartsAsync("archive", [secondId, firstId], new StorageWriteOptions { IfAbsent = true, ContentType = "application/zip" }, TestContext.Current.CancellationToken); committed.Length.ShouldBe(11); committed.ContentType.ShouldBe("application/zip"); - await using var stream = await resumedParts.OpenObjectReadAsync("archive"); + await using var stream = await resumedParts.OpenObjectReadAsync("archive", cancellationToken: TestContext.Current.CancellationToken); using var reader = new StreamReader(stream); - (await reader.ReadToEndAsync()).ShouldBe("secondfirst"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("secondfirst"); (await Should.ThrowAsync(() => resumedParts.CommitPartsAsync("archive", [firstId], new StorageWriteOptions { IfAbsent = true }))).IsConflict.ShouldBeTrue(); } @@ -83,33 +83,30 @@ public async Task VerifiedUpload_RetryWithSameContentReusesRevision_AndDifferent { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(); + await objects.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var metadata = new Dictionary { ["owner"] = "company-a", ["generation"] = "1" }; using var firstContent = Content("original"); - var first = await objects.WriteIfAbsentOrSameAsync("file.txt", firstContent, 8, - new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }); + var first = await objects.WriteIfAbsentOrSameAsync("file.txt", firstContent, 8, new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }, cancellationToken: TestContext.Current.CancellationToken); first.ReusedExisting.ShouldBeFalse(); using var retryContent = Content("original"); - var retry = await objects.WriteIfAbsentOrSameAsync("file.txt", retryContent, 8, - new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }); + var retry = await objects.WriteIfAbsentOrSameAsync("file.txt", retryContent, 8, new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }, cancellationToken: TestContext.Current.CancellationToken); retry.ReusedExisting.ShouldBeTrue(); retry.Info.ETag.ShouldBe(first.Info.ETag); retry.Sha256.ShouldBe(first.Sha256); using var differentlyCasedMetadata = Content("original"); - var sameMetadata = await objects.WriteIfAbsentOrSameAsync("file.txt", differentlyCasedMetadata, 8, - new StorageWriteOptions + var sameMetadata = await objects.WriteIfAbsentOrSameAsync("file.txt", differentlyCasedMetadata, 8, new StorageWriteOptions + { + ContentType = "text/plain", + Metadata = new Dictionary { - ContentType = "text/plain", - Metadata = new Dictionary - { - ["Owner"] = "company-a", - ["Generation"] = "1" - } - }); + ["Owner"] = "company-a", + ["Generation"] = "1" + } + }, cancellationToken: TestContext.Current.CancellationToken); sameMetadata.ReusedExisting.ShouldBeTrue(); sameMetadata.Info.ETag.ShouldBe(first.Info.ETag); using var wrongType = Content("original"); @@ -153,9 +150,9 @@ public async Task VerifiedUpload_RetryWithSameContentReusesRevision_AndDifferent using var differentContent = Content("altered!"); (await Should.ThrowAsync(() => objects.WriteIfAbsentOrSameAsync("file.txt", differentContent, 8))).IsConflict.ShouldBeTrue(); - await using var stored = await objects.OpenObjectReadAsync("file.txt"); + await using var stored = await objects.OpenObjectReadAsync("file.txt", cancellationToken: TestContext.Current.CancellationToken); using var reader = new StreamReader(stored); - (await reader.ReadToEndAsync()).ShouldBe("original"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("original"); } [Fact] @@ -163,37 +160,37 @@ public async Task VerifiedUpload_DeclaredLengthMismatchFailsWithoutAcceptingCont { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(); + await objects.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); using var tooLong = Content("too long"); (await Should.ThrowAsync(() => objects.WriteIfAbsentOrSameAsync("too-long.txt", tooLong, 3))).TooLarge.ShouldBeTrue(); using var tooShort = Content("short"); (await Should.ThrowAsync(() => objects.WriteIfAbsentOrSameAsync("too-short.txt", tooShort, 9))).TooLarge.ShouldBeFalse(); - (await objects.ObjectExistsAsync("too-long.txt")).ShouldBeFalse(); - (await objects.ObjectExistsAsync("too-short.txt")).ShouldBeFalse(); + (await objects.ObjectExistsAsync("too-long.txt", TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await objects.ObjectExistsAsync("too-short.txt", TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] public async Task ImmutableVfsBytes_AreAtomicAndInvalidateExistenceCache() { using var storage = CreateStorage(); - await storage.RequireObjectStorage().CreatePrivateContainerAsync(); + await storage.RequireObjectStorage().CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); using var cache = new MemoryCache(new MemoryCacheOptions()); await using var fileSystem = new VfsImplementation(storage, new TestMetadataManager(storage), Options.Create(new VfsOptions { EnableCache = true }), cache, NullLogger.Instance); var path = new VfsPath("/commands/payload.json"); - (await fileSystem.FileExistsAsync(path)).ShouldBeFalse(); + (await fileSystem.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeFalse(); var metadata = new Dictionary { ["owner"] = "one" }; var options = new StorageWriteOptions { ContentType = "application/json", Metadata = metadata }; var bytes = Encoding.UTF8.GetBytes("{\"ok\":true}"); - var first = await fileSystem.WriteBytesIfAbsentOrSameAsync(path, bytes, options); + var first = await fileSystem.WriteBytesIfAbsentOrSameAsync(path, bytes, options, TestContext.Current.CancellationToken); first.ReusedExisting.ShouldBeFalse(); - (await fileSystem.FileExistsAsync(path)).ShouldBeTrue(); + (await fileSystem.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeTrue(); - var retry = await fileSystem.WriteBytesIfAbsentOrSameAsync(path, bytes, options); + var retry = await fileSystem.WriteBytesIfAbsentOrSameAsync(path, bytes, options, TestContext.Current.CancellationToken); retry.ReusedExisting.ShouldBeTrue(); retry.Info.ETag.ShouldBe(first.Info.ETag); (await Should.ThrowAsync(() => @@ -210,56 +207,56 @@ public async Task MetadataPaginationAndDeletion_PreserveContainerBoundary() { using var storage = CreateStorage(); var objects = storage.RequireObjectStorage(); - await objects.CreatePrivateContainerAsync(new Dictionary { ["owner"] = "one" }); - await objects.SetContainerMetadataAsync(new Dictionary { ["owner"] = "two" }); + await objects.CreatePrivateContainerAsync(new Dictionary { ["owner"] = "one" }, TestContext.Current.CancellationToken); + await objects.SetContainerMetadataAsync(new Dictionary { ["owner"] = "two" }, TestContext.Current.CancellationToken); for (var index = 0; index < 3; index++) { using var content = Content("data"); - await objects.WriteObjectAsync($"files/{index}", content); + await objects.WriteObjectAsync($"files/{index}", content, cancellationToken: TestContext.Current.CancellationToken); } - var page = await objects.ListObjectsAsync("files/", pageSize: 2); + var page = await objects.ListObjectsAsync("files/", pageSize: 2, cancellationToken: TestContext.Current.CancellationToken); page.Items.Count.ShouldBe(2); page.ContinuationToken.ShouldNotBeNullOrEmpty(); - var next = await objects.ListObjectsAsync("files/", page.ContinuationToken, 2); + var next = await objects.ListObjectsAsync("files/", page.ContinuationToken, 2, TestContext.Current.CancellationToken); next.Items.Count.ShouldBe(1); next.Items[0].Path.ShouldBe("files/2"); next.ContinuationToken.ShouldBeNull(); - (await objects.ListObjectsAsync("files/", pageSize: 1000)).ContinuationToken.ShouldBeNull(); - (await objects.ListObjectsAsync("absent/")).ContinuationToken.ShouldBeNull(); + (await objects.ListObjectsAsync("files/", pageSize: 1000, cancellationToken: TestContext.Current.CancellationToken)).ContinuationToken.ShouldBeNull(); + (await objects.ListObjectsAsync("absent/", cancellationToken: TestContext.Current.CancellationToken)).ContinuationToken.ShouldBeNull(); var info = next.Items[0]; - (await objects.GetObjectInfoAsync(info.Path)).ETag.ShouldBe(info.ETag); - await objects.SetObjectMetadataAsync(info.Path, new Dictionary { ["hash"] = "value" }, info.ETag); - (await objects.GetObjectInfoAsync(info.Path)).Metadata["hash"].ShouldBe("value"); + (await objects.GetObjectInfoAsync(info.Path, TestContext.Current.CancellationToken)).ETag.ShouldBe(info.ETag); + await objects.SetObjectMetadataAsync(info.Path, new Dictionary { ["hash"] = "value" }, info.ETag, TestContext.Current.CancellationToken); + (await objects.GetObjectInfoAsync(info.Path, TestContext.Current.CancellationToken)).Metadata["hash"].ShouldBe("value"); (await Should.ThrowAsync(() => objects.SetObjectMetadataAsync(info.Path, new Dictionary(), info.ETag))).IsConflict.ShouldBeTrue(); - (await objects.DeleteObjectIfExistsAsync(info.Path, includeSnapshots: true)).ShouldBeTrue(); - (await objects.ObjectExistsAsync(info.Path)).ShouldBeFalse(); + (await objects.DeleteObjectIfExistsAsync(info.Path, includeSnapshots: true, cancellationToken: TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await objects.ObjectExistsAsync(info.Path, TestContext.Current.CancellationToken)).ShouldBeFalse(); (await Should.ThrowAsync(() => objects.GetObjectInfoAsync(info.Path))).IsNotFound.ShouldBeTrue(); - (await objects.GetContainerInfoAsync()).Metadata["owner"].ShouldBe("two"); - (await objects.DeleteContainerIfExistsAsync()).ShouldBeTrue(); - (await objects.DeleteContainerIfExistsAsync()).ShouldBeFalse(); + (await objects.GetContainerInfoAsync(TestContext.Current.CancellationToken)).Metadata["owner"].ShouldBe("two"); + (await objects.DeleteContainerIfExistsAsync(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await objects.DeleteContainerIfExistsAsync(TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] public async Task VfsEmptyWrites_CreateAndTruncateStoredFiles() { using var storage = CreateStorage(); - await storage.RequireObjectStorage().CreatePrivateContainerAsync(); + await storage.RequireObjectStorage().CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var services = new ServiceCollection(); services.AddLogging(); services.AddVirtualFileSystem(storage, options => options.EnableCache = false); await using var provider = services.BuildServiceProvider(); var vfs = provider.GetRequiredService(); - var empty = await vfs.GetFileAsync(new VfsPath("/empty.txt")); - await empty.WriteAllBytesAsync([]); - (await storage.RequireObjectStorage().GetObjectInfoAsync("empty.txt")).Length.ShouldBe(0); - await using (var emptyRead = await storage.RequireObjectStorage().OpenObjectReadAsync("empty.txt")) + var empty = await vfs.GetFileAsync(new VfsPath("/empty.txt"), TestContext.Current.CancellationToken); + await empty.WriteAllBytesAsync([], cancellationToken: TestContext.Current.CancellationToken); + (await storage.RequireObjectStorage().GetObjectInfoAsync("empty.txt", TestContext.Current.CancellationToken)).Length.ShouldBe(0); + await using (var emptyRead = await storage.RequireObjectStorage().OpenObjectReadAsync("empty.txt", cancellationToken: TestContext.Current.CancellationToken)) { - (await emptyRead.ReadAsync(new byte[1])).ShouldBe(0); + (await emptyRead.ReadAsync(new byte[1], TestContext.Current.CancellationToken)).ShouldBe(0); } - var populated = await vfs.GetFileAsync(new VfsPath("/populated.txt")); - await populated.WriteAllTextAsync("previous"); - await populated.WriteAllBytesAsync([], new ManagedCode.Storage.VirtualFileSystem.Options.WriteOptions { Overwrite = true }); - (await storage.RequireObjectStorage().GetObjectInfoAsync("populated.txt")).Length.ShouldBe(0); + var populated = await vfs.GetFileAsync(new VfsPath("/populated.txt"), TestContext.Current.CancellationToken); + await populated.WriteAllTextAsync("previous", cancellationToken: TestContext.Current.CancellationToken); + await populated.WriteAllBytesAsync([], new ManagedCode.Storage.VirtualFileSystem.Options.WriteOptions { Overwrite = true }, TestContext.Current.CancellationToken); + (await storage.RequireObjectStorage().GetObjectInfoAsync("populated.txt", TestContext.Current.CancellationToken)).Length.ShouldBe(0); } private AzureStorage CreateStorage(string? name = null) => new(new AzureStorageOptions diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureRuntimeOptionsContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureRuntimeOptionsContractsTests.cs new file mode 100644 index 0000000..e1d733e --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureRuntimeOptionsContractsTests.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Azure.Identity; +using ManagedCode.Storage.Azure; +using ManagedCode.Storage.Azure.Options; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.FileSystem.Options; +using ManagedCode.Storage.Tests.Storages.Registration; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Azure; + +public sealed class AzureRuntimeOptionsContractsTests(AzureRegistrationHost host) : IClassFixture +{ + [Fact] + public async Task OptionsAndWriteStreams_TargetTheNewContainerAndKeepOriginalOptionsIndependent() + { + var token = TestContext.Current.CancellationToken; + var original = new AzureStorageOptions { ConnectionString = host.ConnectionString, Container = "options-" + Guid.NewGuid().ToString("N"), CreateContainerIfNotExists = true }; + var storage = (AzureStorage)AzureStorageConnection.Create(host.ConnectionString, original.Container); + await storage.StorageClient.CreateIfNotExistsAsync(cancellationToken: token); + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + (await storage.SetStorageOptions((IStorageOptions)original, token)).IsSuccess.ShouldBeTrue(); + var second = "options-" + Guid.NewGuid().ToString("N"); + (await storage.SetStorageOptions((IStorageOptions options) => ((AzureStorageOptions)options).Container = second, token)).IsSuccess.ShouldBeTrue(); + original.Container.ShouldNotBe(second); + storage.StorageClient.Name.ShouldBe(second); + var bytes = RandomNumberGenerator.GetBytes(4107); + var opened = await storage.OpenWriteStreamAsync("stream.bin", token); + opened.IsSuccess.ShouldBeTrue(opened.Problem?.ToString()); + await using (var write = opened.Value!) await write.WriteAsync(bytes, token); + var read = await storage.GetStreamAsync("stream.bin", token); + read.IsSuccess.ShouldBeTrue(); + await using (var stream = read.Value!) + { + await using var output = new MemoryStream(); + await stream.CopyToAsync(output, token); + output.ToArray().ShouldBe(bytes); + } + (await storage.SetStorageOptions((IStorageOptions)new FileSystemStorageOptions(), token)).IsFailed.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(true, "missing.bin", token)).IsFailed.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("missing.bin", token)).IsFailed.ShouldBeTrue(); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + (await ((AzureStorage)AzureStorageConnection.Create(host.ConnectionString, original.Container)).RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task CredentialOptionsCallback_PreservesSdkReferencesAndKeepsTheOriginalIndependent() + { + var token = new CancellationToken(canceled: true); + var credential = new EnvironmentCredential(); + var original = new AzureStorageCredentialsOptions + { + ServiceUri = new Uri("https://account.blob.core.windows.net/"), + ContainerName = "original", + Container = "original", + Credentials = credential + }; + var storage = new AzureStorage(original); + var result = await storage.SetStorageOptions((IStorageOptions options) => + { + var copy = options.ShouldBeOfType(); + copy.ShouldNotBeSameAs(original); + copy.Credentials.ShouldBeSameAs(credential); + copy.ServiceUri.ShouldBe(original.ServiceUri); + copy.Container = "changed"; + copy.ContainerName = "changed"; + }, token); + result.IsFailed.ShouldBeTrue(); + original.ContainerName.ShouldBe("original"); + storage.StorageClient.Uri.AbsoluteUri.ShouldBe("https://account.blob.core.windows.net/changed"); + } + + [Theory] + [InlineData("http://example.invalid/")] + [InlineData("https://example.invalid/path")] + [InlineData("https://example.invalid/?key=value")] + [InlineData("https://example.invalid/#fragment")] + public void CredentialEndpoint_MustBeAnHttpsOrigin(string endpoint) => + Should.Throw(() => AzureStorageConnection.Create(endpoint, "files", new EnvironmentCredential())); + + [Fact] + public void ValidCredentialEndpoint_PreservesTheActualSdkCredentialWithoutAuthenticating() + { + var credential = new EnvironmentCredential(); + var storage = (AzureStorage)AzureStorageConnection.Create("https://account.blob.core.windows.net/", "files", credential); + storage.StorageClient.Uri.AbsoluteUri.ShouldBe("https://account.blob.core.windows.net/files"); + Should.Throw(() => AzureStorageConnection.Create("https://account.blob.core.windows.net/", "files")); + Should.Throw(() => AzureStorageConnection.Create(" ", "files")); + Should.Throw(() => AzureStorageConnection.Create("https://account.blob.core.windows.net/", " ", credential)); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureUploadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureUploadTests.cs index 30d3b2b..1a49d2f 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureUploadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureUploadTests.cs @@ -49,7 +49,7 @@ protected override ServiceProvider ConfigureServices() [InlineData(false)] public async Task UploadAsync_ReturnsCompleteMetadata_WithoutReadingBlobProperties(bool seekable) { - var createResult = await Storage.CreateContainerAsync(); + var createResult = await Storage.CreateContainerAsync(TestContext.Current.CancellationToken); createResult.IsSuccess.ShouldBeTrue(); _requestPolicy.Reset(); @@ -65,7 +65,7 @@ public async Task UploadAsync_ReturnsCompleteMetadata_WithoutReadingBlobProperti Metadata = new Dictionary { ["purpose"] = "regression" } }; - var result = await Storage.UploadAsync(source, options); + var result = await Storage.UploadAsync(source, options, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldNotBeNull(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserFileRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserFileRuntimeContractsTests.cs new file mode 100644 index 0000000..4a12580 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserFileRuntimeContractsTests.cs @@ -0,0 +1,27 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.Playwright; +using Xunit; +using static Microsoft.Playwright.Assertions; + +namespace ManagedCode.Storage.Tests.Storages.Browser; + +[Collection(BrowserIntegrationCollection.Server)] +public sealed class BrowserFileRuntimeContractsTests(BrowserServerHostFixture fixture) +{ + [Theory] + [InlineData(4107)] + [InlineData(524309)] + public async Task ActualBrowserFile_StreamsThroughBothStorageAndControllerUploadPaths(int size) + { + await using var context = await fixture.CreateContextAsync(); + var page = await context.NewPageAsync(); + await page.GotoAsync("/browser-file-contracts"); + await Expect(page.Locator("#browser-file")).ToBeVisibleAsync(); + await page.WaitForFunctionAsync("() => document.querySelector('#browser-file')._blazorInputFileNextFileId !== undefined"); + var bytes = RandomNumberGenerator.GetBytes(size); + await page.Locator("#browser-file").SetInputFilesAsync(new FilePayload { Name = "source.storagecontract-" + Guid.NewGuid().ToString("N"), MimeType = "application/octet-stream", Buffer = bytes }); + await Expect(page.Locator("#browser-file-status")).ToHaveTextAsync(Convert.ToHexString(SHA256.HashData(bytes)), new() { Timeout = 60000 }); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserPlaywrightHostFixtureBase.cs b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserPlaywrightHostFixtureBase.cs index 8aa85b0..b5e281c 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserPlaywrightHostFixtureBase.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserPlaywrightHostFixtureBase.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Playwright; using Xunit; @@ -15,6 +16,7 @@ public abstract class BrowserPlaywrightHostFixtureBase(string relativeProjectPat private readonly StringBuilder _hostOutput = new(); private Process? _hostProcess; private IPlaywright? _playwright; + private readonly bool _isServerHost = relativeProjectPath.Contains("BrowserServerHost", StringComparison.Ordinal); public string BaseUrl { get; private set; } = string.Empty; @@ -26,7 +28,7 @@ public abstract class BrowserPlaywrightHostFixtureBase(string relativeProjectPat public string ContainerKey => $"managedcode.browser.indexeddb::{ContainerName}"; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { BaseUrl = $"http://127.0.0.1:{GetAvailablePort()}"; _hostProcess = StartHostProcess(BaseUrl, relativeProjectPath); @@ -39,13 +41,14 @@ public async Task InitializeAsync() }); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (Browser is not null) await Browser.DisposeAsync(); _playwright?.Dispose(); - StopHostProcess(); + await StopHostProcessAsync(); + GC.SuppressFinalize(this); } public Task CreateContextAsync() @@ -77,7 +80,9 @@ private Process StartHostProcess(string baseUrl, string relativeProjectPath) var startInfo = new ProcessStartInfo("dotnet") { - Arguments = $"run --project \"{projectPath}\" --configuration Release --no-build --no-launch-profile", + Arguments = _isServerHost + ? $"\"{Path.Combine(AppContext.BaseDirectory, "ManagedCode.Storage.BrowserServerHost.dll")}\"" + : $"run --project \"{projectPath}\" --configuration Release --no-build --no-launch-profile", WorkingDirectory = workingDirectory, RedirectStandardError = true, RedirectStandardOutput = true, @@ -157,15 +162,30 @@ private string GetHostOutput() } } - private void StopHostProcess() + private async Task StopHostProcessAsync() { if (_hostProcess is null) return; - if (!_hostProcess.HasExited) - _hostProcess.Kill(entireProcessTree: true); - - _hostProcess.Dispose(); - _hostProcess = null; + try + { + if (!_hostProcess.HasExited && _isServerHost) + { + using var client = new HttpClient { BaseAddress = new Uri(BaseUrl), Timeout = TimeSpan.FromSeconds(5) }; + using var response = await client.PostAsync("/__test/shutdown", null); + response.EnsureSuccessStatusCode(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await _hostProcess.WaitForExitAsync(timeout.Token); + if (_hostProcess.ExitCode != 0) + throw new InvalidOperationException($"Browser host failed during shutdown.{Environment.NewLine}{GetHostOutput()}"); + } + } + finally + { + if (!_hostProcess.HasExited) + _hostProcess.Kill(entireProcessTree: true); + _hostProcess.Dispose(); + _hostProcess = null; + } } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserServerCoverageContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserServerCoverageContractsTests.cs new file mode 100644 index 0000000..a091c4d --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Browser/BrowserServerCoverageContractsTests.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; +using Xunit; +using static Microsoft.Playwright.Assertions; + +namespace ManagedCode.Storage.Tests.Storages.Browser; + +[Collection(BrowserIntegrationCollection.Server)] +public sealed class BrowserServerCoverageContractsTests(BrowserServerHostFixture fixture) +{ + [Fact] + public async Task RegistrationsDownloadsRangesAndLegalHold_UseActualBrowserState() + { + await using var context = await fixture.CreateContextAsync(); + var page = await context.NewPageAsync(); + await page.GotoAsync("/coverage-playground"); + await Expect(page.Locator("#run-contracts")).ToBeVisibleAsync(); + await page.ClickAsync("#run-contracts"); + await Expect(page.Locator("#contract-status")).ToHaveTextAsync("all-contracts-passed", new() { Timeout = 60000 }); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageAdditionalFlowTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageAdditionalFlowTests.cs index 0d3b497..13b2767 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageAdditionalFlowTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageAdditionalFlowTests.cs @@ -18,13 +18,13 @@ public async Task SetStorageOptions_WhenStreamIsOpen_KeepsItsOriginalSnapshot() { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - await using var original = (await storage.GetStreamAsync("root.txt")).Value.ShouldNotBeNull(); + await using var original = (await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldNotBeNull(); var second = fixture.WriteArtifact("second.ctg", entries => entries[0] = CartographArtifactFixture.Change(entries[0], path: "changed.txt")); - (await storage.SetStorageOptions(options => options.ArtifactPath = second)).IsSuccess.ShouldBeTrue(); - (await storage.ExistsAsync("root.txt")).Value.ShouldBeFalse(); - (await storage.ExistsAsync("changed.txt")).Value.ShouldBeTrue(); + (await storage.SetStorageOptions(options => options.ArtifactPath = second, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + (await storage.ExistsAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldBeFalse(); + (await storage.ExistsAsync("changed.txt", TestContext.Current.CancellationToken)).Value.ShouldBeTrue(); var bytes = new byte[fixture.Files["root.txt"].Length]; - await original.ReadExactlyAsync(bytes); + await original.ReadExactlyAsync(bytes, TestContext.Current.CancellationToken); bytes.ShouldBe(fixture.Files["root.txt"]); } @@ -33,16 +33,16 @@ public async Task GetStreamAsync_WhenArrayReadUsed_HonorsOffsetsAndEmptyBuffers( { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - await using var stream = (await storage.GetStreamAsync("root.txt")).Value.ShouldNotBeNull(); + await using var stream = (await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldNotBeNull(); stream.Read(Span.Empty).ShouldBe(0); - (await stream.ReadAsync(Memory.Empty)).ShouldBe(0); + (await stream.ReadAsync(Memory.Empty, TestContext.Current.CancellationToken)).ShouldBe(0); var buffer = new byte[8]; var count = await stream.ReadAsync(buffer, 2, 3, CancellationToken.None); count.ShouldBe(3); buffer.AsSpan(2, 3).ToArray().ShouldBe(fixture.Files["root.txt"].AsSpan(0, 3).ToArray()); buffer[0].ShouldBe((byte)0); stream.Position.ShouldBe(3); - await stream.FlushAsync(); + await stream.FlushAsync(TestContext.Current.CancellationToken); } [Fact] @@ -63,10 +63,10 @@ public async Task CreateContainerAsync_WhenCatalogIsEmpty_AllowsAnEmptyFileColle using var fixture = new CartographArtifactFixture(); var path = fixture.WriteArtifact("empty.ctg", entries => entries.Clear()); using var storage = fixture.CreateStorage(path); - (await storage.CreateContainerAsync()).IsSuccess.ShouldBeTrue(); - (await storage.ExistsAsync("root.txt")).Value.ShouldBeFalse(); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + (await storage.ExistsAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldBeFalse(); var entries = new List(); - await foreach (var metadata in storage.GetBlobMetadataListAsync()) + await foreach (var metadata in storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) entries.Add(metadata); entries.ShouldBeEmpty(); } @@ -79,7 +79,7 @@ public async Task CreateContainerAsync_WhenArtifactHasNoCatalog_RejectsIt() writer.AddSegment().AddRecord("arbitrary record bytes"u8); writer.Save(fixture.DestinationPath); using var storage = fixture.CreateStorage(fixture.DestinationPath); - (await storage.CreateContainerAsync()).IsFailed.ShouldBeTrue(); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); } [Theory] @@ -89,15 +89,15 @@ public async Task DownloadAsync_WhenPathsLinkToArtifact_LeavesSourceUnchanged(bo { using var fixture = new CartographArtifactFixture(); File.CreateSymbolicLink(fixture.DestinationPath, fixture.ArtifactPath); - var original = await File.ReadAllBytesAsync(fixture.ArtifactPath); + var original = await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken); using var storage = fixture.CreateStorage(destinationIsLink ? fixture.ArtifactPath : fixture.DestinationPath); var result = await storage.DownloadAsync(new DownloadOptions { FileName = "root.txt", LocalPath = destinationIsLink ? fixture.DestinationPath : fixture.ArtifactPath - }); + }, TestContext.Current.CancellationToken); result.IsFailed.ShouldBeTrue(); - (await File.ReadAllBytesAsync(fixture.ArtifactPath)).ShouldBe(original); + (await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken)).ShouldBe(original); } [Fact] @@ -105,10 +105,10 @@ public async Task DownloadAsync_WhenPayloadCorrupted_PreservesExistingDestinatio { using var fixture = new CartographArtifactFixture(); fixture.CorruptFirstPayload(); - await File.WriteAllTextAsync(fixture.DestinationPath, "existing destination"); + await File.WriteAllTextAsync(fixture.DestinationPath, "existing destination", TestContext.Current.CancellationToken); using var storage = fixture.CreateStorage(); - var result = await storage.DownloadAsync(new DownloadOptions { FileName = "root.txt", LocalPath = fixture.DestinationPath }); + var result = await storage.DownloadAsync(new DownloadOptions { FileName = "root.txt", LocalPath = fixture.DestinationPath }, TestContext.Current.CancellationToken); result.IsFailed.ShouldBeTrue(); - (await File.ReadAllTextAsync(fixture.DestinationPath)).ShouldBe("existing destination"); + (await File.ReadAllTextAsync(fixture.DestinationPath, TestContext.Current.CancellationToken)).ShouldBe("existing destination"); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageMetadataTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageMetadataTests.cs index e3d9e89..edf99c0 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageMetadataTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageMetadataTests.cs @@ -16,7 +16,7 @@ public async Task GetBlobMetadataAsync_WhenUsingDirectoryOptions_ReturnsCatalogM { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - var result = await storage.GetBlobMetadataAsync(new MetadataOptions { Directory = "reports/nested", FileName = "дані.txt" }); + var result = await storage.GetBlobMetadataAsync(new MetadataOptions { Directory = "reports/nested", FileName = "дані.txt" }, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var metadata = result.Value.ShouldNotBeNull(); metadata.FullName.ShouldBe("reports/nested/дані.txt"); @@ -42,7 +42,7 @@ public async Task GetBlobMetadataListAsync_WhenDirectorySelected_ReturnsOnlyItsD using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); var entries = new List(); - await foreach (var entry in storage.GetBlobMetadataListAsync(directory)) + await foreach (var entry in storage.GetBlobMetadataListAsync(directory, TestContext.Current.CancellationToken)) entries.Add(entry); entries.Count.ShouldBe(count); @@ -60,7 +60,7 @@ public async Task ExistsAsync_WhenPathQueried_UsesOrdinalRelativeNames(string pa { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - var result = await storage.ExistsAsync(path); + var result = await storage.ExistsAsync(path, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(exists); } @@ -83,10 +83,10 @@ public async Task GetBlobMetadataListAsync_WhenOptionsChange_KeepsMetadataOnItsO { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - await using var entries = storage.GetBlobMetadataListAsync().GetAsyncEnumerator(); + await using var entries = storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken); (await entries.MoveNextAsync()).ShouldBeTrue(); var second = fixture.WriteArtifact("second.ctg"); - (await storage.SetStorageOptions(options => options.ArtifactPath = second)).IsSuccess.ShouldBeTrue(); + (await storage.SetStorageOptions(options => options.ArtifactPath = second, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); (await entries.MoveNextAsync()).ShouldBeTrue(); entries.Current.Container.ShouldBe(fixture.ArtifactPath); entries.Current.Uri.ShouldNotBeNull().LocalPath.ShouldBe(fixture.ArtifactPath); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageReadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageReadTests.cs index 082d0a2..d645246 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageReadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageReadTests.cs @@ -20,8 +20,8 @@ public async Task GetStreamAsync_WhenCatalogContainsFile_ReadsItsExactBytes(stri { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - (await storage.CreateContainerAsync()).IsSuccess.ShouldBeTrue(); - var result = await storage.GetStreamAsync(path); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var result = await storage.GetStreamAsync(path, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); await using var stream = result.Value.ShouldNotBeNull(); stream.CanRead.ShouldBeTrue(); @@ -30,7 +30,7 @@ public async Task GetStreamAsync_WhenCatalogContainsFile_ReadsItsExactBytes(stri stream.Length.ShouldBe(fixture.Files[path].LongLength); stream.Position.ShouldBe(0); using var output = new MemoryStream(); - await stream.CopyToAsync(output, bufferSize: 997); + await stream.CopyToAsync(output, bufferSize: 997, cancellationToken: TestContext.Current.CancellationToken); output.ToArray().ShouldBe(fixture.Files[path]); stream.Position.ShouldBe(stream.Length); stream.ReadByte().ShouldBe(-1); @@ -41,7 +41,7 @@ public async Task GetStreamAsync_WhenReadSynchronously_CrossesRecordBoundaries() { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - using var stream = (await storage.GetStreamAsync("reports/large.bin")).Value.ShouldNotBeNull(); + using var stream = (await storage.GetStreamAsync("reports/large.bin", TestContext.Current.CancellationToken)).Value.ShouldNotBeNull(); var actual = new byte[fixture.Files["reports/large.bin"].Length]; stream.ReadExactly(actual); actual.ShouldBe(fixture.Files["reports/large.bin"]); @@ -74,7 +74,7 @@ public async Task GetStreamAsync_WhenDisposed_RejectsFurtherReads() { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - var stream = (await storage.GetStreamAsync("root.txt")).Value.ShouldNotBeNull(); + var stream = (await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldNotBeNull(); stream.ReadByte().ShouldBe(fixture.Files["root.txt"][0]); await stream.DisposeAsync(); stream.CanRead.ShouldBeFalse(); @@ -94,10 +94,10 @@ public async Task DownloadAsync_WhenFileSpansRecords_WritesExactContentAndMetada Directory = "reports", FileName = "large.bin", LocalPath = explicitPath ? fixture.DestinationPath : null - }); + }, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); var file = result.Value.ShouldNotBeNull(); - (await File.ReadAllBytesAsync(file.FilePath)).ShouldBe(fixture.Files["reports/large.bin"]); + (await File.ReadAllBytesAsync(file.FilePath, TestContext.Current.CancellationToken)).ShouldBe(fixture.Files["reports/large.bin"]); file.BlobMetadata.ShouldNotBeNull().FullName.ShouldBe("reports/large.bin"); var path = file.FilePath; await file.DisposeAsync(); @@ -114,7 +114,7 @@ public async Task GetStreamAsync_WhenCancellationRequested_FailsAndCancelsAnOpen var result = await storage.GetStreamAsync("root.txt", cancellation.Token); result.IsFailed.ShouldBeTrue(); result.Problem.ShouldNotBeNull().Title.ShouldBe(nameof(OperationCanceledException)); - await using var stream = (await storage.GetStreamAsync("root.txt")).Value.ShouldNotBeNull(); + await using var stream = (await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken)).Value.ShouldNotBeNull(); await Should.ThrowAsync(async () => await stream.ReadExactlyAsync(new byte[1], cancellation.Token)); stream.Position.ShouldBe(0); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageRegistrationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageRegistrationTests.cs index 03e58db..aef897a 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageRegistrationTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageRegistrationTests.cs @@ -87,9 +87,9 @@ public async Task VirtualFileSystem_WhenUsingCartograph_ReadsCataloguedFiles() await using var scope = provider.CreateAsyncScope(); var vfs = scope.ServiceProvider.GetRequiredService(); var path = new VfsPath("/reports/nested/дані.txt"); - (await vfs.FileExistsAsync(path)).ShouldBeTrue(); - var file = await vfs.GetFileAsync(path); - (await file.ReadAllTextAsync()).ShouldBe(System.Text.Encoding.UTF8.GetString(fixture.Files["reports/nested/дані.txt"])); + (await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeTrue(); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + (await file.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(System.Text.Encoding.UTF8.GetString(fixture.Files["reports/nested/дані.txt"])); } private static async Task AssertReadAsync(IStorage storage, CartographArtifactFixture fixture) diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageValidationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageValidationTests.cs index 4f50802..af07e17 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageValidationTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Cartograph/CartographStorageValidationTests.cs @@ -21,8 +21,8 @@ public async Task GetStreamAsync_WhenPathIsUnsafe_ReturnsFailure(string path) { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - (await storage.GetStreamAsync(path)).IsFailed.ShouldBeTrue(); - (await storage.ExistsAsync(path)).IsFailed.ShouldBeTrue(); + (await storage.GetStreamAsync(path, TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); + (await storage.ExistsAsync(path, TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); } [Fact] @@ -30,10 +30,10 @@ public async Task ReadOperations_WhenFileMissing_ReportFailureOrFalseExistence() { using var fixture = new CartographArtifactFixture(); using var storage = fixture.CreateStorage(); - (await storage.GetStreamAsync("missing.txt")).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(FileNotFoundException)); - (await storage.DownloadAsync("missing.txt")).IsFailed.ShouldBeTrue(); - (await storage.GetBlobMetadataAsync("missing.txt")).IsFailed.ShouldBeTrue(); - (await storage.ExistsAsync("missing.txt")).Value.ShouldBeFalse(); + (await storage.GetStreamAsync("missing.txt", TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(FileNotFoundException)); + (await storage.DownloadAsync("missing.txt", TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); + (await storage.GetBlobMetadataAsync("missing.txt", TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); + (await storage.ExistsAsync("missing.txt", TestContext.Current.CancellationToken)).Value.ShouldBeFalse(); } [Theory] @@ -67,8 +67,8 @@ public async Task CreateContainerAsync_WhenCatalogIsInvalid_RejectsArtifact(stri entries.Add(entry); }); using var storage = fixture.CreateStorage(path); - (await storage.CreateContainerAsync()).IsFailed.ShouldBeTrue(); - (await storage.GetStreamAsync("root.txt")).IsFailed.ShouldBeTrue(); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); + (await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); } [Fact] @@ -77,12 +77,12 @@ public async Task GetStreamAsync_WhenPayloadCorrupted_DefersChecksumFailureUntil using var fixture = new CartographArtifactFixture(); fixture.CorruptFirstPayload(); using var storage = fixture.CreateStorage(); - (await storage.GetBlobMetadataAsync("root.txt")).IsSuccess.ShouldBeTrue(); - var result = await storage.GetStreamAsync("root.txt"); + (await storage.GetBlobMetadataAsync("root.txt", TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var result = await storage.GetStreamAsync("root.txt", TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); await using var stream = result.Value.ShouldNotBeNull(); await Should.ThrowAsync(async () => await stream.ReadExactlyAsync(new byte[1])); - (await storage.DownloadAsync("root.txt")).IsFailed.ShouldBeTrue(); + (await storage.DownloadAsync("root.txt", TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); } [Fact] @@ -91,36 +91,36 @@ public async Task CreateContainerAsync_WhenArtifactMissingOrMalformed_NeverCreat using var fixture = new CartographArtifactFixture(); var missing = fixture.ArtifactPath + ".missing"; using var storage = fixture.CreateStorage(missing); - (await storage.CreateContainerAsync()).IsFailed.ShouldBeTrue(); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); File.Exists(missing).ShouldBeFalse(); - await File.WriteAllTextAsync(missing, "not a Cartograph artifact"); - (await storage.CreateContainerAsync()).IsFailed.ShouldBeTrue(); - (await File.ReadAllTextAsync(missing)).ShouldBe("not a Cartograph artifact"); + await File.WriteAllTextAsync(missing, "not a Cartograph artifact", TestContext.Current.CancellationToken); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsFailed.ShouldBeTrue(); + (await File.ReadAllTextAsync(missing, TestContext.Current.CancellationToken)).ShouldBe("not a Cartograph artifact"); } [Fact] public async Task DownloadAsync_WhenDestinationIsArtifact_LeavesSourceUnchanged() { using var fixture = new CartographArtifactFixture(); - var original = await File.ReadAllBytesAsync(fixture.ArtifactPath); + var original = await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken); using var storage = fixture.CreateStorage(); - var result = await storage.DownloadAsync(new DownloadOptions { FileName = "root.txt", LocalPath = fixture.ArtifactPath }); + var result = await storage.DownloadAsync(new DownloadOptions { FileName = "root.txt", LocalPath = fixture.ArtifactPath }, TestContext.Current.CancellationToken); result.IsFailed.ShouldBeTrue(); - (await File.ReadAllBytesAsync(fixture.ArtifactPath)).ShouldBe(original); + (await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken)).ShouldBe(original); } [Fact] public async Task MutationOperations_WhenCalled_ReturnUnsupportedAndPreserveArtifact() { using var fixture = new CartographArtifactFixture(); - var original = await File.ReadAllBytesAsync(fixture.ArtifactPath); + var original = await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken); using var storage = fixture.CreateStorage(); - (await storage.UploadAsync("replacement", new UploadOptions { FileName = "root.txt" })).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await storage.DeleteAsync("root.txt")).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await storage.DeleteDirectoryAsync("reports")).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await storage.RemoveContainerAsync()).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await storage.SetLegalHoldAsync(true, "root.txt")).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await storage.HasLegalHoldAsync("root.txt")).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); - (await File.ReadAllBytesAsync(fixture.ArtifactPath)).ShouldBe(original); + (await storage.UploadAsync("replacement", new UploadOptions { FileName = "root.txt" }, TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await storage.DeleteAsync("root.txt", TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await storage.DeleteDirectoryAsync("reports", TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await storage.RemoveContainerAsync(TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await storage.SetLegalHoldAsync(true, "root.txt", TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await storage.HasLegalHoldAsync("root.txt", TestContext.Current.CancellationToken)).Problem.ShouldNotBeNull().Title.ShouldBe(nameof(NotSupportedException)); + (await File.ReadAllBytesAsync(fixture.ArtifactPath, TestContext.Current.CancellationToken)).ShouldBe(original); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs index 65a013a..c234b75 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudDriveStorageTests.cs @@ -35,28 +35,28 @@ public async Task OneDrive_FakeClient_RoundTrip() RootPath = "root" }); - var uploadResult = await storage.UploadAsync("hello world", options => options.FileName = "text.txt"); + var uploadResult = await storage.UploadAsync("hello world", options => options.FileName = "text.txt", TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); uploadResult.Value.FullName.ShouldBe("text.txt"); uploadResult.Value.Container.ShouldBe("drive"); - var exists = await storage.ExistsAsync("text.txt"); + var exists = await storage.ExistsAsync("text.txt", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); - var metadata = await storage.GetBlobMetadataAsync("text.txt"); + var metadata = await storage.GetBlobMetadataAsync("text.txt", TestContext.Current.CancellationToken); metadata.IsSuccess.ShouldBeTrue(); metadata.Value.Name.ShouldBe("text.txt"); metadata.Value.FullName.ShouldBe("text.txt"); metadata.Value.Container.ShouldBe("drive"); - var download = await storage.DownloadAsync("text.txt"); + var download = await storage.DownloadAsync("text.txt", TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); using var reader = new StreamReader(download.Value.FileStream); - (await reader.ReadToEndAsync()).ShouldBe("hello world"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("hello world"); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync()) + await foreach (var item in storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) { listed.Add(item); } @@ -75,7 +75,7 @@ public async Task OneDrive_RemoveContainer_NotSupported() RootPath = "root" }); - var result = await storage.RemoveContainerAsync(); + var result = await storage.RemoveContainerAsync(TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeFalse(); } @@ -89,27 +89,27 @@ public async Task GoogleDrive_FakeClient_RoundTrip() RootFolderId = "root" }); - var uploadResult = await storage.UploadAsync("drive content", options => options.FileName = "data.bin"); + var uploadResult = await storage.UploadAsync("drive content", options => options.FileName = "data.bin", TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); uploadResult.Value.FullName.ShouldBe("data.bin"); uploadResult.Value.Container.ShouldBe("root"); - var exists = await storage.ExistsAsync("data.bin"); + var exists = await storage.ExistsAsync("data.bin", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); - var metadata = await storage.GetBlobMetadataAsync("data.bin"); + var metadata = await storage.GetBlobMetadataAsync("data.bin", TestContext.Current.CancellationToken); metadata.IsSuccess.ShouldBeTrue(); metadata.Value.FullName.ShouldBe("data.bin"); metadata.Value.Container.ShouldBe("root"); - var download = await storage.DownloadAsync("data.bin"); + var download = await storage.DownloadAsync("data.bin", TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); using var reader = new StreamReader(download.Value.FileStream); - (await reader.ReadToEndAsync()).ShouldBe("drive content"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("drive content"); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync()) + await foreach (var item in storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) { listed.Add(item); } @@ -127,30 +127,30 @@ public async Task Dropbox_FakeClient_RoundTrip() RootPath = "/apps/demo" }); - var uploadResult = await storage.UploadAsync("dropbox payload", options => options.FileName = "file.json"); + var uploadResult = await storage.UploadAsync("dropbox payload", options => options.FileName = "file.json", TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); uploadResult.Value.FullName.ShouldBe("file.json"); uploadResult.Value.Container.ShouldBe("/apps/demo"); uploadResult.Value.MimeType.ShouldBe("application/json"); - var exists = await storage.ExistsAsync("file.json"); + var exists = await storage.ExistsAsync("file.json", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); - var metadata = await storage.GetBlobMetadataAsync("file.json"); + var metadata = await storage.GetBlobMetadataAsync("file.json", TestContext.Current.CancellationToken); metadata.IsSuccess.ShouldBeTrue(); metadata.Value.Name.ShouldBe("file.json"); metadata.Value.FullName.ShouldBe("file.json"); metadata.Value.Container.ShouldBe("/apps/demo"); metadata.Value.MimeType.ShouldBe("application/json"); - var download = await storage.DownloadAsync("file.json"); + var download = await storage.DownloadAsync("file.json", TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); using var reader = new StreamReader(download.Value.FileStream); - (await reader.ReadToEndAsync()).ShouldBe("dropbox payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("dropbox payload"); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync()) + await foreach (var item in storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) { listed.Add(item); } @@ -173,29 +173,29 @@ public async Task OneDrive_DeleteDirectory_ShouldDeleteOnlyDirectoryContent() { options.Directory = "dir"; options.FileName = "a.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); (await storage.UploadAsync("dir-2", options => { options.Directory = "dir"; options.FileName = "b.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - (await storage.UploadAsync("keep", options => options.FileName = "keep.txt")).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("keep", options => options.FileName = "keep.txt", TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - var deleteResult = await storage.DeleteDirectoryAsync("dir"); + var deleteResult = await storage.DeleteDirectoryAsync("dir", TestContext.Current.CancellationToken); deleteResult.IsSuccess.ShouldBeTrue(); - var dirAExists = await storage.ExistsAsync("dir/a.txt"); + var dirAExists = await storage.ExistsAsync("dir/a.txt", TestContext.Current.CancellationToken); dirAExists.IsSuccess.ShouldBeTrue(); dirAExists.Value.ShouldBeFalse(); - var keepExists = await storage.ExistsAsync("keep.txt"); + var keepExists = await storage.ExistsAsync("keep.txt", TestContext.Current.CancellationToken); keepExists.IsSuccess.ShouldBeTrue(); keepExists.Value.ShouldBeTrue(); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync("dir")) + await foreach (var item in storage.GetBlobMetadataListAsync("dir", TestContext.Current.CancellationToken)) { listed.Add(item); } @@ -217,29 +217,29 @@ public async Task GoogleDrive_DeleteDirectory_ShouldDeleteOnlyDirectoryContent() { options.Directory = "dir"; options.FileName = "a.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); (await storage.UploadAsync("dir-2", options => { options.Directory = "dir"; options.FileName = "b.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - (await storage.UploadAsync("keep", options => options.FileName = "keep.txt")).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("keep", options => options.FileName = "keep.txt", TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - var deleteResult = await storage.DeleteDirectoryAsync("dir"); + var deleteResult = await storage.DeleteDirectoryAsync("dir", TestContext.Current.CancellationToken); deleteResult.IsSuccess.ShouldBeTrue(); - var dirAExists = await storage.ExistsAsync("dir/a.txt"); + var dirAExists = await storage.ExistsAsync("dir/a.txt", TestContext.Current.CancellationToken); dirAExists.IsSuccess.ShouldBeTrue(); dirAExists.Value.ShouldBeFalse(); - var keepExists = await storage.ExistsAsync("keep.txt"); + var keepExists = await storage.ExistsAsync("keep.txt", TestContext.Current.CancellationToken); keepExists.IsSuccess.ShouldBeTrue(); keepExists.Value.ShouldBeTrue(); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync("dir")) + await foreach (var item in storage.GetBlobMetadataListAsync("dir", TestContext.Current.CancellationToken)) { listed.Add(item); } @@ -261,29 +261,29 @@ public async Task Dropbox_DeleteDirectory_ShouldDeleteOnlyDirectoryContent() { options.Directory = "dir"; options.FileName = "a.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); (await storage.UploadAsync("dir-2", options => { options.Directory = "dir"; options.FileName = "b.txt"; - })).IsSuccess.ShouldBeTrue(); + }, TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - (await storage.UploadAsync("keep", options => options.FileName = "keep.txt")).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("keep", options => options.FileName = "keep.txt", TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - var deleteResult = await storage.DeleteDirectoryAsync("dir"); + var deleteResult = await storage.DeleteDirectoryAsync("dir", TestContext.Current.CancellationToken); deleteResult.IsSuccess.ShouldBeTrue(); - var dirAExists = await storage.ExistsAsync("dir/a.txt"); + var dirAExists = await storage.ExistsAsync("dir/a.txt", TestContext.Current.CancellationToken); dirAExists.IsSuccess.ShouldBeTrue(); dirAExists.Value.ShouldBeFalse(); - var keepExists = await storage.ExistsAsync("keep.txt"); + var keepExists = await storage.ExistsAsync("keep.txt", TestContext.Current.CancellationToken); keepExists.IsSuccess.ShouldBeTrue(); keepExists.Value.ShouldBeTrue(); var listed = new List(); - await foreach (var item in storage.GetBlobMetadataListAsync("dir")) + await foreach (var item in storage.GetBlobMetadataListAsync("dir", TestContext.Current.CancellationToken)) { listed.Add(item); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudRegistrationContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudRegistrationContractsTests.cs new file mode 100644 index 0000000..bd5a75e --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/CloudRegistrationContractsTests.cs @@ -0,0 +1,120 @@ +using System.Net.Http; +using System.Linq; +using Google.Apis.Drive.v3; +using Google.Apis.Services; +using ManagedCode.Storage.CloudKit; +using ManagedCode.Storage.CloudKit.Extensions; +using ManagedCode.Storage.CloudKit.Options; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Providers; +using ManagedCode.Storage.Dropbox; +using ManagedCode.Storage.Dropbox.Extensions; +using ManagedCode.Storage.Dropbox.Options; +using ManagedCode.Storage.GoogleDrive; +using ManagedCode.Storage.GoogleDrive.Extensions; +using ManagedCode.Storage.GoogleDrive.Options; +using ManagedCode.Storage.OneDrive; +using ManagedCode.Storage.OneDrive.Extensions; +using ManagedCode.Storage.OneDrive.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Graph; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.CloudDrive; + +public sealed class CloudRegistrationContractsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Dropbox_KeyedTenantsAndDefaultOptionsRemainIndependent(bool asDefault) + { + var services = new ServiceCollection().AddLogging(); + foreach (var key in new[] { "a", "b" }) + { + if (asDefault) services.AddDropboxStorageAsDefault(key, o => { o.AccessToken = "registration-only"; o.RootPath = "/" + key; }); + else services.AddDropboxStorage(key, o => { o.AccessToken = "registration-only"; o.RootPath = "/" + key; }); + } + services.AddDropboxStorageAsDefault(new DropboxStorageOptions { AccessToken = "registration-only", RootPath = "/default" }); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredKeyedService("a").ShouldNotBeSameAs(provider.GetRequiredKeyedService("b")); + provider.GetRequiredKeyedService("a").RootPath.ShouldBe("/a"); + if (asDefault) provider.GetRequiredKeyedService("a").ShouldBeSameAs(provider.GetRequiredKeyedService("a")); + var copy = (DropboxStorageOptions)provider.GetServices().Single(p => p is DropboxStorageProvider).GetDefaultOptions(); + copy.RootPath = "/changed"; + provider.GetRequiredService().RootPath.ShouldBe("/default"); + copy.AccessToken.ShouldBe("registration-only"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GoogleDrive_KeyedTenantsAndDefaultOptionsPreserveTheSdkClient(bool asDefault) + { + using var drive = new DriveService(new BaseClientService.Initializer { ApplicationName = "Storage registration contracts" }); + var services = new ServiceCollection().AddLogging(); + foreach (var key in new[] { "a", "b" }) + { + if (asDefault) services.AddGoogleDriveStorageAsDefault(key, o => { o.DriveService = drive; o.RootFolderId = key; }); + else services.AddGoogleDriveStorage(key, o => { o.DriveService = drive; o.RootFolderId = key; }); + } + services.AddGoogleDriveStorageAsDefault(new GoogleDriveStorageOptions { DriveService = drive, RootFolderId = "default", SupportsAllDrives = true }); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredKeyedService("a").ShouldNotBeSameAs(provider.GetRequiredKeyedService("b")); + provider.GetRequiredKeyedService("a").RootFolderId.ShouldBe("a"); + if (asDefault) provider.GetRequiredKeyedService("a").ShouldBeSameAs(provider.GetRequiredKeyedService("a")); + var copy = (GoogleDriveStorageOptions)provider.GetServices().Single(p => p is GoogleDriveStorageProvider).GetDefaultOptions(); + copy.RootFolderId = "changed"; + provider.GetRequiredService().RootFolderId.ShouldBe("default"); + copy.DriveService.ShouldBeSameAs(drive); + copy.SupportsAllDrives.ShouldBeTrue(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OneDrive_KeyedTenantsAndDefaultOptionsPreserveTheSdkClient(bool asDefault) + { + using var http = new HttpClient(); + var graph = new GraphServiceClient(http); + var services = new ServiceCollection().AddLogging(); + foreach (var key in new[] { "a", "b" }) + { + if (asDefault) services.AddOneDriveStorageAsDefault(key, o => { o.GraphClient = graph; o.DriveId = key; }); + else services.AddOneDriveStorage(key, o => { o.GraphClient = graph; o.DriveId = key; }); + } + services.AddOneDriveStorageAsDefault(new OneDriveStorageOptions { GraphClient = graph, DriveId = "default" }); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredKeyedService("a").ShouldNotBeSameAs(provider.GetRequiredKeyedService("b")); + provider.GetRequiredKeyedService("a").DriveId.ShouldBe("a"); + if (asDefault) provider.GetRequiredKeyedService("a").ShouldBeSameAs(provider.GetRequiredKeyedService("a")); + var copy = (OneDriveStorageOptions)provider.GetServices().Single(p => p is OneDriveStorageProvider).GetDefaultOptions(); + copy.DriveId = "changed"; + provider.GetRequiredService().DriveId.ShouldBe("default"); + copy.GraphClient.ShouldBeSameAs(graph); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CloudKit_KeyedTenantsAndDefaultOptionsPreserveTheHttpClient(bool asDefault) + { + using var http = new HttpClient(); + var services = new ServiceCollection().AddLogging(); + foreach (var key in new[] { "a", "b" }) + { + if (asDefault) services.AddCloudKitStorageAsDefault(key, o => { o.ContainerId = "iCloud.org.storage-tests"; o.ApiToken = "registration-only"; o.HttpClient = http; o.RootPath = key; }); + else services.AddCloudKitStorage(key, o => { o.ContainerId = "iCloud.org.storage-tests"; o.ApiToken = "registration-only"; o.HttpClient = http; o.RootPath = key; }); + } + services.AddCloudKitStorageAsDefault(new CloudKitStorageOptions { ContainerId = "iCloud.org.storage-tests", ApiToken = "registration-only", HttpClient = http, RootPath = "default" }); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredKeyedService("a").ShouldNotBeSameAs(provider.GetRequiredKeyedService("b")); + provider.GetRequiredKeyedService("a").RootPath.ShouldBe("a"); + if (asDefault) provider.GetRequiredKeyedService("a").ShouldBeSameAs(provider.GetRequiredKeyedService("a")); + var copy = (CloudKitStorageOptions)provider.GetServices().Single(p => p is CloudKitStorageProvider).GetDefaultOptions(); + copy.RootPath = "changed"; + provider.GetRequiredService().RootPath.ShouldBe("default"); + copy.HttpClient.ShouldBeSameAs(http); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/DropboxClientWrapperHttpTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/DropboxClientWrapperHttpTests.cs index 03c0a5d..9f63021 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/DropboxClientWrapperHttpTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/DropboxClientWrapperHttpTests.cs @@ -47,7 +47,7 @@ public async Task DropboxClientWrapper_WithHttpHandler_RoundTrip() await using (var downloaded = await wrapper.DownloadAsync("/apps/demo", "file.json", CancellationToken.None)) using (var reader = new StreamReader(downloaded, Encoding.UTF8)) { - (await reader.ReadToEndAsync()).ShouldBe("dropbox payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("dropbox payload"); } var items = new List(); @@ -81,17 +81,17 @@ public async Task DropboxStorage_WithAccessTokenAndHttpHandler_RoundTrip() CreateContainerIfNotExists = true }); - (await storage.UploadAsync("dropbox payload", options => options.FileName = "file.json")).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("dropbox payload", options => options.FileName = "file.json", TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); - var exists = await storage.ExistsAsync("file.json"); + var exists = await storage.ExistsAsync("file.json", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); - var download = await storage.DownloadAsync("file.json"); + var download = await storage.DownloadAsync("file.json", TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); using (var reader = new StreamReader(download.Value.FileStream, Encoding.UTF8)) { - (await reader.ReadToEndAsync()).ShouldBe("dropbox payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("dropbox payload"); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs index 0d560c3..a740cdd 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GoogleDriveClientHttpTests.cs @@ -52,7 +52,7 @@ public async Task GoogleDriveClient_WithHttpHandler_RoundTrip() await using (var downloaded = await client.DownloadAsync(TestOptions.RootFolderId, "dir/file.txt", TestOptions.SupportsAllDrives, CancellationToken.None)) using (var reader = new StreamReader(downloaded)) { - (await reader.ReadToEndAsync()).ShouldBe("google payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("google payload"); } var listed = new List(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GraphOneDriveClientTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GraphOneDriveClientTests.cs index c33a5b7..60254fb 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GraphOneDriveClientTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudDrive/GraphOneDriveClientTests.cs @@ -55,7 +55,7 @@ public async Task GraphClient_EndToEnd() await using (var downloaded = await storageClient.DownloadAsync("me", "work/doc.txt", CancellationToken.None)) using (var reader = new StreamReader(downloaded)) { - (await reader.ReadToEndAsync()).ShouldBe("graph payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("graph payload"); } var listed = new List(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitClientHttpTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitClientHttpTests.cs index 221f2c8..7dd1d55 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitClientHttpTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitClientHttpTests.cs @@ -54,7 +54,7 @@ public async Task CloudKitClient_WithHttpHandler_RoundTrip() await using (var downloaded = await client.DownloadAsync(recordName, CancellationToken.None)) using (var reader = new StreamReader(downloaded, Encoding.UTF8)) { - (await reader.ReadToEndAsync()).ShouldBe("cloudkit payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("cloudkit payload"); } var listed = new List(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitStorageTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitStorageTests.cs index ec75084..56a8ce6 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/CloudKit/CloudKitStorageTests.cs @@ -31,27 +31,27 @@ public async Task CloudKitStorage_RoundTrip_WithHttpHandler() { options.Directory = "dir"; options.FileName = "file.txt"; - }); + }, TestContext.Current.CancellationToken); upload.IsSuccess.ShouldBeTrue(); upload.Value.FullName.ShouldBe("dir/file.txt"); upload.Value.Container.ShouldBe("iCloud.com.example.app"); - var download = await storage.DownloadAsync("dir/file.txt"); + var download = await storage.DownloadAsync("dir/file.txt", TestContext.Current.CancellationToken); download.IsSuccess.ShouldBeTrue(); using (var reader = new StreamReader(download.Value.FileStream, Encoding.UTF8)) { - (await reader.ReadToEndAsync()).ShouldBe("storage payload"); + (await reader.ReadToEndAsync(TestContext.Current.CancellationToken)).ShouldBe("storage payload"); } - var existsBeforeDelete = await storage.ExistsAsync("dir/file.txt"); + var existsBeforeDelete = await storage.ExistsAsync("dir/file.txt", TestContext.Current.CancellationToken); existsBeforeDelete.IsSuccess.ShouldBeTrue(); existsBeforeDelete.Value.ShouldBeTrue(); - var deleteDir = await storage.DeleteDirectoryAsync("dir"); + var deleteDir = await storage.DeleteDirectoryAsync("dir", TestContext.Current.CancellationToken); deleteDir.IsSuccess.ShouldBeTrue(); - var existsAfterDelete = await storage.ExistsAsync("dir/file.txt"); + var existsAfterDelete = await storage.ExistsAsync("dir/file.txt", TestContext.Current.CancellationToken); existsAfterDelete.IsSuccess.ShouldBeTrue(); existsAfterDelete.Value.ShouldBeFalse(); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/DataLake/AzureDataLakeProtocolTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/DataLake/AzureDataLakeProtocolTests.cs new file mode 100644 index 0000000..33d02e9 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/DataLake/AzureDataLakeProtocolTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Azure; +using ManagedCode.Storage.Azure.DataLake; +using ManagedCode.Storage.Azure.DataLake.Extensions; +using ManagedCode.Storage.Azure.DataLake.Options; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.DataLake; + +public sealed class AzureDataLakeProtocolTests(DataLakeProtocolHost host) : IClassFixture +{ + private AzureDataLakeStorageOptions Options() => new() + { + ConnectionString = host.ConnectionString, + FileSystem = "contract-" + Guid.NewGuid().ToString("N") + }; + + [Theory] + [InlineData(null)] + [InlineData("nested")] + public async Task FilesAndStreams_RoundTripThroughRealAzureSdk(string? directory) + { + var options = Options(); + var storage = new AzureDataLakeStorage(options); + var token = TestContext.Current.CancellationToken; + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + if (directory is not null) + (await storage.CreateDirectoryAsync(directory, token)).IsSuccess.ShouldBeTrue(); + var uploadOptions = new UploadOptions { FileName = "content.bin", Directory = directory }; + var content = Encoding.UTF8.GetBytes("0123456789-Привіт"); + var uploaded = await storage.UploadAsync(content, uploadOptions, token); + uploaded.IsSuccess.ShouldBeTrue(uploaded.Problem?.ToString()); + uploaded.Value!.Length.ShouldBe((ulong)content.Length); + var key = directory is null ? "content.bin" : directory + "/content.bin"; + (await storage.ExistsAsync(key, token)).Value.ShouldBeTrue(); + var downloaded = await storage.DownloadAsync(key, token); + downloaded.IsSuccess.ShouldBeTrue(downloaded.Problem?.ToString()); + await using (var local = downloaded.Value!) + (await local.ReadAllBytesAsync(token)).ShouldBe(content); + var opened = await storage.OpenReadStreamAsync(new OpenReadStreamOptions + { + FileName = "content.bin", + Directory = directory, + Position = 3, + BufferSize = 4096 + }, token); + opened.IsSuccess.ShouldBeTrue(opened.Problem?.ToString()); + await using (var stream = opened.Value!) + { + using var reader = new StreamReader(stream); + (await reader.ReadToEndAsync(token)).ShouldBe(Encoding.UTF8.GetString(content.AsSpan(3))); + } + var write = await storage.OpenWriteStreamAsync(new OpenWriteStreamOptions { FileName = "streamed", Directory = directory, Overwrite = true }, token); + write.IsSuccess.ShouldBeTrue(write.Problem?.ToString()); + await using (var stream = write.Value!) + await stream.WriteAsync(content, token); + var names = new List(); + await foreach (var metadata in storage.GetBlobMetadataListAsync(directory, token)) + names.Add(metadata.Name!); + names.ShouldContain(key); + if (directory is not null) + { + (await storage.RenameDirectory(directory, "renamed", token)).IsSuccess.ShouldBeTrue(); + key = "renamed/content.bin"; + } + (await storage.DeleteAsync(key, token)).IsSuccess.ShouldBeTrue(); + (await storage.ExistsAsync(key, token)).Value.ShouldBeFalse(); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task PermissionFailures_AreReturnedAcrossProviderOperations() + { + var options = Options(); + var storage = new AzureDataLakeStorage(options); + var token = TestContext.Current.CancellationToken; + (await storage.UploadAsync("seed", new UploadOptions { FileName = "seed" }, token)).IsSuccess.ShouldBeTrue(); + host.Reject(options.FileSystem); + (await storage.UploadAsync("new", new UploadOptions { FileName = "seed" }, token)).IsSuccess.ShouldBeFalse(); + (await storage.DownloadAsync("seed", token)).IsSuccess.ShouldBeFalse(); + (await storage.GetStreamAsync("seed", token)).IsSuccess.ShouldBeFalse(); + (await storage.OpenWriteStreamAsync(new OpenWriteStreamOptions { FileName = "seed", Overwrite = true }, token)).IsSuccess.ShouldBeFalse(); + (await storage.GetBlobMetadataAsync("seed", token)).IsSuccess.ShouldBeFalse(); + (await storage.ExistsAsync("seed", token)).IsSuccess.ShouldBeFalse(); + (await storage.DeleteAsync("seed", token)).IsSuccess.ShouldBeFalse(); + (await storage.CreateDirectoryAsync("directory", token)).IsSuccess.ShouldBeFalse(); + (await storage.RenameDirectory("directory", "renamed", token)).IsSuccess.ShouldBeFalse(); + (await storage.DeleteDirectoryAsync("directory", token)).IsSuccess.ShouldBeFalse(); + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeFalse(); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeFalse(); + await Should.ThrowAsync(async () => + { + await foreach (var _ in storage.GetBlobMetadataListAsync(cancellationToken: token)) { } + }); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task Registrations_ResolveConfiguredRealProvider(int registration) + { + var options = Options(); + var services = new ServiceCollection(); + void Configure(AzureDataLakeStorageOptions target) { target.ConnectionString = options.ConnectionString; target.FileSystem = options.FileSystem; } + switch (registration) + { + case 0: services.AddAzureDataLakeStorage(options); break; + case 1: services.AddAzureDataLakeStorage(Configure); break; + case 2: services.AddAzureDataLakeStorageAsDefault(options); break; + case 3: services.AddAzureDataLakeStorageAsDefault(Configure); break; + case 4: services.AddAzureDataLakeStorage("lake", Configure); break; + case 5: services.AddAzureDataLakeStorageAsDefault("lake", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + var storage = registration >= 4 ? provider.GetRequiredKeyedService("lake") : provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("registered", new UploadOptions { FileName = "registered" }, token)).IsSuccess.ShouldBeTrue(); + var opened = await storage.GetStreamAsync("registered", token); + opened.IsSuccess.ShouldBeTrue(opened.Problem?.ToString()); + await using var stream = opened.Value!; + using var reader = new StreamReader(stream); + (await reader.ReadToEndAsync(token)).ShouldBe("registered"); + if (registration is 2 or 3) + provider.GetRequiredService().ShouldBeOfType(); + if (registration == 5) + provider.GetRequiredKeyedService("lake").ShouldBeSameAs(storage); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/DataLake/DataLakeProtocolHost.cs b/Tests/ManagedCode.Storage.Tests/Storages/DataLake/DataLakeProtocolHost.cs new file mode 100644 index 0000000..8a56336 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/DataLake/DataLakeProtocolHost.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.DataLake; + +// External Azure protocol simulation: real Azure SDK requests over Kestrel, backed by real files. +// Azurite does not implement the ADLS Gen2 DFS protocol. This is not hosted-Azure qualification. +public sealed class DataLakeProtocolHost : IAsyncLifetime +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "adls-contract", Guid.NewGuid().ToString("N")); + private readonly ConcurrentDictionary _rejected = new(); + private WebApplication _app = null!; + public string ConnectionString { get; private set; } = string.Empty; + public void Reject(string filesystem) => _rejected[filesystem] = true; + + public async ValueTask InitializeAsync() + { + Directory.CreateDirectory(_root); + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + _app = builder.Build(); + _app.Run(HandleAsync); + await _app.StartAsync(TestContext.Current.CancellationToken); + var endpoint = _app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + ConnectionString = $"DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey={key};BlobEndpoint={endpoint}/devstoreaccount1;"; + } + + public async ValueTask DisposeAsync() + { + await _app.DisposeAsync(); + Directory.Delete(_root, recursive: true); + } + + private async Task HandleAsync(HttpContext context) + { + var segments = context.Request.Path.Value!.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2 || segments.Any(s => s is "." or "..")) + { + context.Response.StatusCode = 400; + return; + } + var filesystem = segments[1]; + if (_rejected.ContainsKey(filesystem)) + { + await ErrorAsync(context, 403, "AuthorizationPermissionMismatch"); + return; + } + var filesystemPath = Path.Combine(_root, filesystem); + var path = Path.Combine(_root, Path.Combine(segments.Skip(1).ToArray())); + context.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString("N"); + context.Response.Headers["x-ms-version"] = "2023-11-03"; + switch (context.Request.Method) + { + case "PUT": await CreateAsync(context, path, filesystemPath); break; + case "PATCH": await UpdateAsync(context, path); break; + case "DELETE": + if (!File.Exists(path) && !Directory.Exists(path)) { await ErrorAsync(context, 404, "PathNotFound"); break; } + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); else File.Delete(path); + context.Response.StatusCode = path == filesystemPath ? 202 : 200; + break; + case "HEAD": + case "GET": await ReadAsync(context, path, filesystemPath); break; + default: context.Response.StatusCode = 405; break; + } + } + + private async Task CreateAsync(HttpContext context, string path, string filesystemPath) + { + var rename = context.Request.Headers["x-ms-rename-source"].ToString(); + if (rename.Length > 0) + { + var parts = rename.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts[0] == "devstoreaccount1") parts = parts[1..]; + var source = Path.Combine(_root, Path.Combine(parts)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (File.Exists(source)) File.Move(source, path); else Directory.Move(source, path); + } + else if (path == filesystemPath || context.Request.Query["resource"] == "directory") + Directory.CreateDirectory(path); + else + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var stream = File.Create(path); + } + context.Response.StatusCode = 201; + SetProperties(context, path); + } + + private static async Task UpdateAsync(HttpContext context, string path) + { + if (!File.Exists(path)) { await ErrorAsync(context, 404, "PathNotFound"); return; } + var position = long.Parse(context.Request.Query["position"].ToString(), CultureInfo.InvariantCulture); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + if (context.Request.Query["action"] == "append") + { + stream.Position = position; + await context.Request.Body.CopyToAsync(stream, context.RequestAborted); + context.Response.StatusCode = 202; + } + else + { + stream.SetLength(position); + context.Response.StatusCode = 200; + } + SetProperties(context, path); + } + + private static async Task ReadAsync(HttpContext context, string path, string filesystemPath) + { + if (!File.Exists(path) && !Directory.Exists(path)) { await ErrorAsync(context, 404, "PathNotFound"); return; } + SetProperties(context, path); + if (context.Request.Method == "HEAD") return; + if (context.Request.Query["resource"] == "filesystem") + { + var directory = context.Request.Query["directory"].ToString(); + var scan = Path.Combine(filesystemPath, directory); + var paths = Directory.Exists(scan) ? Directory.GetFileSystemEntries(scan, "*", SearchOption.AllDirectories) : Array.Empty(); + await context.Response.WriteAsJsonAsync(new + { + paths = paths.Select(p => new + { + name = Path.GetRelativePath(filesystemPath, p).Replace('\\', '/'), + isDirectory = Directory.Exists(p) ? "true" : "false", + contentLength = File.Exists(p) ? new FileInfo(p).Length.ToString(CultureInfo.InvariantCulture) : "0", + lastModified = File.GetLastWriteTimeUtc(p).ToString("R", CultureInfo.InvariantCulture), + etag = "\"test\"" + }) + }, context.RequestAborted); + return; + } + var bytes = await File.ReadAllBytesAsync(path, context.RequestAborted); + var range = context.Request.Headers["x-ms-range"].ToString(); + if (range.Length == 0) range = context.Request.Headers.Range.ToString(); + if (range.StartsWith("bytes=", StringComparison.Ordinal)) + { + var limits = range[6..].Split('-'); + var start = int.Parse(limits[0], CultureInfo.InvariantCulture); + var end = limits[1].Length > 0 ? Math.Min(int.Parse(limits[1], CultureInfo.InvariantCulture), bytes.Length - 1) : bytes.Length - 1; + context.Response.StatusCode = 206; + context.Response.Headers.ContentRange = $"bytes {start}-{end}/{bytes.Length}"; + bytes = bytes[start..(end + 1)]; + } + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, context.RequestAborted); + } + + private static void SetProperties(HttpContext context, string path) + { + var modified = File.GetLastWriteTimeUtc(path).ToString("R", CultureInfo.InvariantCulture); + context.Response.Headers.ETag = "\"test\""; + context.Response.Headers.LastModified = modified; + context.Response.Headers["x-ms-creation-time"] = modified; + context.Response.Headers["x-ms-resource-type"] = Directory.Exists(path) ? "directory" : "file"; + context.Response.Headers["x-ms-blob-type"] = "BlockBlob"; + context.Response.Headers["x-ms-lease-status"] = "unlocked"; + context.Response.Headers["x-ms-lease-state"] = "available"; + context.Response.ContentType = "application/octet-stream"; + if (context.Request.Method == "HEAD") context.Response.ContentLength = File.Exists(path) ? new FileInfo(path).Length : 0; + } + + private static async Task ErrorAsync(HttpContext context, int status, string code) + { + context.Response.StatusCode = status; + context.Response.Headers["x-ms-error-code"] = code; + if (context.Request.Method != "HEAD") + await context.Response.WriteAsJsonAsync(new { error = new { code, message = code } }, context.RequestAborted); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs new file mode 100644 index 0000000..72f522e --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.VirtualFileSystem; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.FileSystem; + +public sealed class FileSystemLegalHoldContractsTests +{ + [Fact] + public async Task HoldTransitionsAndMissingFiles_ReturnObservableResults() + { + await using var session = await LocalVfsSession.CreateAsync(); + var storage = session.Storage; + var token = TestContext.Current.CancellationToken; + (await storage.UploadAsync("held content", new UploadOptions { FileName = "held.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("held.txt", token)).Value.ShouldBeFalse(); + (await storage.SetLegalHoldAsync(true, "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync(options => options.FileName = "held.txt", token)).Value.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(true, "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(false, options => options.FileName = "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync(new LegalHoldOptions { FileName = "held.txt" }, token)).Value.ShouldBeFalse(); + (await storage.SetLegalHoldAsync(true, "missing.txt", token)).IsFailed.ShouldBeTrue(); + (await storage.DeleteAsync("held.txt", token)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemReadOnlyContainerTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemReadOnlyContainerTests.cs index d834ca0..ddc7165 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemReadOnlyContainerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemReadOnlyContainerTests.cs @@ -19,9 +19,9 @@ public async Task ReadsAgainstMissingContainerDoNotCreateContainerOrNestedDirect try { - var exists = await storage.ExistsAsync("nested/settings.json"); - var stream = await storage.GetStreamAsync("nested/settings.json"); - var metadata = await storage.GetBlobMetadataAsync("nested/settings.json"); + var exists = await storage.ExistsAsync("nested/settings.json", TestContext.Current.CancellationToken); + var stream = await storage.GetStreamAsync("nested/settings.json", TestContext.Current.CancellationToken); + var metadata = await storage.GetBlobMetadataAsync("nested/settings.json", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeFalse(); @@ -45,9 +45,9 @@ public async Task ReadsAgainstExistingContainerDoNotCreateMissingNestedDirectori try { - var exists = await storage.ExistsAsync("nested/settings.json"); - var stream = await storage.GetStreamAsync("nested/settings.json"); - var metadata = await storage.GetBlobMetadataAsync("nested/settings.json"); + var exists = await storage.ExistsAsync("nested/settings.json", TestContext.Current.CancellationToken); + var stream = await storage.GetStreamAsync("nested/settings.json", TestContext.Current.CancellationToken); + var metadata = await storage.GetBlobMetadataAsync("nested/settings.json", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeFalse(); @@ -72,7 +72,7 @@ public async Task WritesDoNotCreateMissingContainerWhenAutomaticCreationIsDisabl var upload = await storage.UploadAsync(new byte[] { 1, 2, 3 }, new UploadOptions { FileName = "nested/settings.json" - }); + }, TestContext.Current.CancellationToken); upload.IsFailed.ShouldBeTrue(); Directory.Exists(root).ShouldBeFalse(); @@ -91,7 +91,7 @@ public async Task ExplicitContainerCreationRemainsAvailableWhenAutomaticCreation try { - var created = await storage.CreateContainerAsync(); + var created = await storage.CreateContainerAsync(TestContext.Current.CancellationToken); created.IsSuccess.ShouldBeTrue(); Directory.Exists(root).ShouldBeTrue(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemSecurityTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemSecurityTests.cs index 95892cc..a34ecd8 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemSecurityTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemSecurityTests.cs @@ -46,7 +46,7 @@ public async Task UploadAsync_WithPathTraversal_ShouldFail(string maliciousFileN }; // Act - var result = await _storage.UploadAsync(stream, options); + var result = await _storage.UploadAsync(stream, options, TestContext.Current.CancellationToken); // Assert - security validation should reject path traversal result.IsFailed.ShouldBeTrue(); @@ -65,7 +65,7 @@ public async Task UploadAsync_WithValidFileName_ShouldSucceed() }; // Act - var result = await _storage.UploadAsync(stream, options); + var result = await _storage.UploadAsync(stream, options, TestContext.Current.CancellationToken); // Assert result.IsSuccess.ShouldBeTrue(); @@ -87,7 +87,7 @@ public async Task UploadAsync_WithPathTraversalInDirectory_ShouldFail( }; // Act - var result = await _storage.UploadAsync(stream, options); + var result = await _storage.UploadAsync(stream, options, TestContext.Current.CancellationToken); // Assert - security validation should reject path traversal result.IsFailed.ShouldBeTrue(); @@ -107,7 +107,7 @@ public async Task UploadAsync_WithValidDirectory_ShouldSucceed() }; // Act - var result = await _storage.UploadAsync(stream, options); + var result = await _storage.UploadAsync(stream, options, TestContext.Current.CancellationToken); // Assert result.IsSuccess.ShouldBeTrue(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUnicodeSanitizerTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUnicodeSanitizerTests.cs index 9e0b14e..22762a2 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUnicodeSanitizerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUnicodeSanitizerTests.cs @@ -18,7 +18,7 @@ public async Task ShouldResolveExistingUnicodeFileByPathOnly() Directory.CreateDirectory(directory); var expectedFilePath = Path.Combine(directory, "лист-привіт.txt"); - await File.WriteAllTextAsync(expectedFilePath, "Привіт"); + await File.WriteAllTextAsync(expectedFilePath, "Привіт", TestContext.Current.CancellationToken); var storage = new FileSystemStorage(new FileSystemStorageOptions { @@ -28,11 +28,11 @@ public async Task ShouldResolveExistingUnicodeFileByPathOnly() try { - var exists = await storage.ExistsAsync("international/Українська-папка/лист-привіт.txt"); + var exists = await storage.ExistsAsync("international/Українська-папка/лист-привіт.txt", TestContext.Current.CancellationToken); exists.IsSuccess.ShouldBeTrue(); exists.Value.ShouldBeTrue(); - var metadata = await storage.GetBlobMetadataAsync("international/Українська-папка/лист-привіт.txt"); + var metadata = await storage.GetBlobMetadataAsync("international/Українська-папка/лист-привіт.txt", TestContext.Current.CancellationToken); metadata.IsSuccess.ShouldBeTrue(metadata.Problem?.ToString()); metadata.Value!.FullName.ShouldBe("international/Українська-папка/лист-привіт.txt"); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadTests.cs index 608e32f..6bd1662 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadTests.cs @@ -45,7 +45,7 @@ public async Task UploadAsync_AsStream_CorrectlyOverwritesFiles() { options.FileName = filenameToUse; options.Directory = temporaryDirectory; - }); + }, TestContext.Current.CancellationToken); firstResult.IsSuccess.ShouldBeTrue(); @@ -54,7 +54,7 @@ public async Task UploadAsync_AsStream_CorrectlyOverwritesFiles() { options.FileName = filenameToUse; options.Directory = temporaryDirectory; - }); + }, TestContext.Current.CancellationToken); downloadedResult.IsSuccess.ShouldBeTrue(); // size downloadedResult.Value!.FileInfo.Length.ShouldBe(90 * 1024); @@ -64,7 +64,7 @@ public async Task UploadAsync_AsStream_CorrectlyOverwritesFiles() { options.FileName = filenameToUse; options.Directory = temporaryDirectory; - }); + }, TestContext.Current.CancellationToken); secondResult.IsSuccess.ShouldBeTrue(); @@ -73,14 +73,14 @@ public async Task UploadAsync_AsStream_CorrectlyOverwritesFiles() { options.FileName = filenameToUse; options.Directory = temporaryDirectory; - }); + }, TestContext.Current.CancellationToken); downloadedResult.IsSuccess.ShouldBeTrue(); // size downloadedResult.Value!.FileInfo.Length.ShouldBe(512); // content using var ms = new MemoryStream(); - await downloadedResult.Value!.FileStream.CopyToAsync(ms); + await downloadedResult.Value!.FileStream.CopyToAsync(ms, TestContext.Current.CancellationToken); ms.ToArray().ShouldBe(zeroByteBuffer); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs index 3074f9c..30f4f4b 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSBlobTests.cs @@ -11,8 +11,7 @@ public class GCSBlobTests : BlobTests { protected override FakeGcsServerContainer Build() { - return new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + return GcsContainerFactory.Create(); } protected override ServiceProvider ConfigureServices() diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs index 2b06a27..d678a30 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSContainerTests.cs @@ -9,8 +9,7 @@ public class GCSContainerTests : ContainerTests { protected override FakeGcsServerContainer Build() { - return new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + return GcsContainerFactory.Create(); } protected override ServiceProvider ConfigureServices() diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs index 8ecf44b..27b864e 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSDownloadTests.cs @@ -9,8 +9,7 @@ public class GCSDownloadTests : DownloadTests { protected override FakeGcsServerContainer Build() { - return new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + return GcsContainerFactory.Create(); } protected override ServiceProvider ConfigureServices() diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs index 312255a..a80567d 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GCSUploadTests.cs @@ -11,8 +11,7 @@ public class GCSUploadTests : UploadTests { protected override FakeGcsServerContainer Build() { - return new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + return GcsContainerFactory.Create("filesystem"); } protected override ServiceProvider ConfigureServices() @@ -20,14 +19,13 @@ protected override ServiceProvider ConfigureServices() return GCSConfigurator.ConfigureServices(Container.GetConnectionString()); } - [Theory(Skip = "FakeGcsServer currently throttles uploads beyond ~10MB; skip large-stream scenario for emulator")] + [Theory] [Trait("Category", "LargeFile")] [InlineData(1)] [InlineData(3)] [InlineData(5)] public override Task UploadAsync_LargeStream_ShouldRoundTrip(int gigabytes) { - _ = gigabytes; - return Task.CompletedTask; + return base.UploadAsync_LargeStream_ShouldRoundTrip(gigabytes); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsContainerStartupContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsContainerStartupContractsTests.cs new file mode 100644 index 0000000..28bb0ab --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsContainerStartupContractsTests.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Google.Cloud.Storage.V1; +using ManagedCode.Storage.Tests.Common; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.GCS; + +public class GcsContainerStartupContractsTests +{ + [Theory] + [InlineData("memory")] + [InlineData("filesystem")] + public async Task ConcurrentStartup_UsesCompleteScriptsAndCorrectExternalUploadEndpoints(string backend) + { + var token = TestContext.Current.CancellationToken; + var containers = Enumerable.Range(0, 3).Select(_ => GcsContainerFactory.Create(backend)).ToArray(); + try + { + await Task.WhenAll(containers.Select(async container => + { + await container.StartAsync(token); + var client = new StorageClientBuilder { BaseUri = container.GetConnectionString(), UnauthenticatedAccess = true }.Build(); + var bucket = "startup-" + Guid.NewGuid().ToString("N"); + await client.CreateBucketAsync("storage-tests", bucket, cancellationToken: token); + var bytes = Encoding.UTF8.GetBytes("Complete GCS startup: точні байти " + bucket); + await using var input = new MemoryStream(bytes); + await client.UploadObjectAsync(bucket, "proof.bin", "application/octet-stream", input, cancellationToken: token); + await using var output = new MemoryStream(); + await client.DownloadObjectAsync(bucket, "proof.bin", output, cancellationToken: token); + output.ToArray().ShouldBe(bytes); + })); + } + finally + { + await Task.WhenAll(containers.Select(container => container.DisposeAsync().AsTask())); + } + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsLegalHoldContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsLegalHoldContractsTests.cs new file mode 100644 index 0000000..129e628 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/GCS/GcsLegalHoldContractsTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Google; +using ManagedCode.Storage.Google.Options; +using ManagedCode.Storage.Tests.Storages.Registration; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.GCS; + +public sealed class GcsLegalHoldContractsTests(GcsRegistrationHost host) : IClassFixture +{ + [Fact] + public async Task UnsupportedTemporaryHoldBackend_DoesNotReportAnUnappliedHoldAsSuccessful() + { + using var storage = new GCPStorage(new GCPStorageOptions + { + BucketOptions = new BucketOptions { Bucket = "hold-" + Guid.NewGuid().ToString("N"), ProjectId = "hold-contract" }, + StorageClientBuilder = new global::Google.Cloud.Storage.V1.StorageClientBuilder { UnauthenticatedAccess = true, BaseUri = host.Endpoint } + }); + var token = TestContext.Current.CancellationToken; + (await storage.UploadAsync("held content", new UploadOptions { FileName = "held.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await storage.SetLegalHoldAsync(true, "held.txt", token)).IsFailed.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("held.txt", token)).Value.ShouldBeFalse(); + (await storage.SetLegalHoldAsync(false, "held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.HasLegalHoldAsync("held.txt", token)).Value.ShouldBeFalse(); + (await storage.SetLegalHoldAsync(true, "missing.txt", token)).IsFailed.ShouldBeTrue(); + (await storage.DeleteAsync("held.txt", token)).IsSuccess.ShouldBeTrue(); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/ManagedCodeOrleansClusterFixture.cs b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/ManagedCodeOrleansClusterFixture.cs index 3c3e52b..46b04c6 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/ManagedCodeOrleansClusterFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/ManagedCodeOrleansClusterFixture.cs @@ -53,7 +53,7 @@ public sealed class ManagedCodeOrleansClusterFixture : IAsyncLifetime public TestCluster Cluster { get; private set; } = null!; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _fileSystemRoot = Path.Combine( Path.GetTempPath(), @@ -66,8 +66,7 @@ public async Task InitializeAsync() .Build(); _localStackContainer = AwsContainerFactory.Create(); - _gcpContainer = new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + _gcpContainer = GcsContainerFactory.Create(); _sftpContainer = SftpContainerFactory.Create(); await Task.WhenAll( @@ -95,7 +94,7 @@ await Task.WhenAll( await EnsureContainersCreatedAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { ManagedCodeOrleansClusterSettings.Reset(); diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansConfigurationContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansConfigurationContractsTests.cs new file mode 100644 index 0000000..ff9e269 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansConfigurationContractsTests.cs @@ -0,0 +1,34 @@ +using System; +using ManagedCode.Storage.Core; +using Orleans.Configuration; +using Orleans.Runtime; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Orleans; + +public sealed class OrleansConfigurationContractsTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void InvalidProviderConfiguration_FailsWithItsSpecificReason(int invalid) + { + var options = new ManagedCodeStorageGrainStorageOptions(); + var name = "coverage"; + if (invalid == 0) name = " "; + if (invalid == 1) options.StateDirectory = " "; + if (invalid == 2) options.StorageServiceType = typeof(string); + if (invalid == 3) options.StorageKey = " "; + var exception = Should.Throw(() => new ManagedCodeStorageGrainStorageOptionsValidator(options, name).ValidateConfiguration()); + exception.Message.ShouldContain(invalid switch { 0 => "name", 1 => "StateDirectory", 2 => "StorageServiceType", _ => "StorageKey" }); + } + + [Fact] + public void CustomPath_AllowsAnEmptyDefaultDirectory() + { + new ManagedCodeStorageGrainStorageOptionsValidator(new() { StateDirectory = "", PathBuilder = _ => "state.json", StorageServiceType = typeof(IStorage), StorageKey = "tenant" }, "coverage").ValidateConfiguration(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansRegistrationRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansRegistrationRuntimeContractsTests.cs new file mode 100644 index 0000000..c611d45 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansRegistrationRuntimeContractsTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.FileSystem; +using ManagedCode.Storage.Tests.VirtualFileSystem; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans; +using Orleans.Configuration; +using Orleans.Hosting; +using Orleans.Providers; +using Orleans.Runtime; +using Orleans.Serialization.Serializers; +using Orleans.Storage; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Orleans; + +[Collection(ManagedCodeOrleansClusterCollection.Name)] +public sealed class OrleansRegistrationRuntimeContractsTests(ManagedCodeOrleansClusterFixture fixture) +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + public async Task RealSiloBuilderRegistrations_ResolveTheCorrectStorageAndPersistState(int mode) + { + await using var session = await LocalVfsSession.CreateAsync(); + var services = fixture.GetSiloServiceProvider(); + var serializer = services.GetRequiredService>().Get(ManagedCodeOrleansProviderNames.FileSystem).GrainStorageSerializer; + void Configure(ManagedCodeStorageGrainStorageOptions options) + { + options.GrainStorageSerializer = serializer; + options.PathBuilder = _ => "record.state"; + if (mode == 3 || mode == 8) options.StorageFactory = provider => provider.GetRequiredService(); + } + using var host = new HostBuilder().ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)).UseOrleans(builder => + { + builder.ConfigureServices(s => + { + s.AddSingleton(session.Storage); + s.AddSingleton(session.Storage); + s.AddKeyedSingleton("tenant", session.Storage); + s.AddKeyedSingleton("tenant", session.Storage); + s.AddSingleton(services.GetRequiredService()); + }); + Register(builder, mode, Configure); + }).Build(); + var name = mode < 5 ? ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME : "named"; + var provider = host.Services.GetRequiredKeyedService(name); + if (mode < 5) host.Services.GetRequiredService().ShouldBeSameAs(provider); + var id = GrainId.Create("coverage", Guid.NewGuid().ToString("N")); + var state = new GrainState(new() { Value = "registered" }); + await provider.WriteStateAsync("profile", id, state); + await provider.ReadStateAsync("profile", id, state); + state.State!.Value.ShouldBe("registered"); + (await session.Storage.ExistsAsync("record.state", TestContext.Current.CancellationToken)).Value.ShouldBeTrue(); + await provider.ClearStateAsync("profile", id, state); + } + + private static void Register(ISiloBuilder builder, int mode, Action configure) + { + switch (mode) + { + case 0: builder.AddGrainStorageAsDefault(configure); break; + case 1: builder.AddGrainStorageAsDefault("tenant", configure); break; + case 2: builder.AddGrainStorageAsDefault("tenant", configure); break; + case 3: builder.AddGrainStorageAsDefault(configure); break; + case 4: builder.AddGrainStorageAsDefault((OptionsBuilder options) => options.Configure(configure)); break; + case 5: builder.AddGrainStorage("named", configure); break; + case 6: builder.AddGrainStorage("named", "tenant", configure); break; + case 7: builder.AddGrainStorage("named", "tenant", configure); break; + case 8: builder.AddGrainStorage("named", configure); break; + case 9: builder.AddGrainStorage("named", (OptionsBuilder options) => options.Configure(configure)); break; + default: throw new ArgumentOutOfRangeException(nameof(mode)); + } + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStateRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStateRuntimeContractsTests.cs new file mode 100644 index 0000000..e9ced9f --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStateRuntimeContractsTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.VirtualFileSystem; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans; +using Orleans.Runtime; +using Orleans.Serialization.Activators; +using Orleans.Serialization.Serializers; +using Orleans.Storage; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Orleans; + +[Collection(ManagedCodeOrleansClusterCollection.Name)] +public sealed class OrleansStateRuntimeContractsTests(ManagedCodeOrleansClusterFixture fixture) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Clear_PreservesItsConfiguredPersistenceAndConcurrencyContract(bool delete) + { + await using var session = await LocalVfsSession.CreateAsync(); + var options = CreateOptions("state.json"); + options.DeleteStateOnClear = delete; + var provider = CreateProvider(session.Storage, options); + var id = GrainId.Create("coverage", Guid.NewGuid().ToString("N")); + var state = new GrainState(new() { Value = "persisted", WriteCount = 3 }); + await provider.ReadStateAsync("profile", id, state); + state.State!.Value.ShouldBe(string.Empty); + state.RecordExists.ShouldBeFalse(); + state.State!.Value = "persisted"; + await provider.WriteStateAsync("profile", id, state); + var stale = new GrainState(new()) { ETag = "stale" }; + await Should.ThrowAsync(() => provider.ClearStateAsync("profile", id, stale)); + await Should.ThrowAsync(() => provider.WriteStateAsync("profile", id, new GrainState(new()))); + await provider.ClearStateAsync("profile", id, state); + state.RecordExists.ShouldBeFalse(); + state.State.Value.ShouldBe(string.Empty); + (await session.Storage.ExistsAsync("state.json", TestContext.Current.CancellationToken)).Value.ShouldBe(!delete); + var reload = new GrainState(new()); + await provider.ReadStateAsync("profile", id, reload); + reload.RecordExists.ShouldBeFalse(); + reload.ETag.ShouldBe(state.ETag); + reload.State!.Value = "after-clear"; + await provider.WriteStateAsync("profile", id, reload); + await provider.ReadStateAsync("profile", id, state); + state.State.Value.ShouldBe("after-clear"); + } + + [Theory] + [InlineData("leaf.state")] + [InlineData("/nested\\state.json")] + [InlineData("")] + public async Task Paths_RoundTripWithoutDependingOnActivationLifetime(string path) + { + await using var session = await LocalVfsSession.CreateAsync(); + var provider = CreateProvider(session.Storage, CreateOptions(path)); + var id = GrainId.Create("tenant/entity", "id/with spaces"); + var state = new GrainState(new() { Value = "Привіт" }); + await provider.WriteStateAsync("profile/preferences", id, state); + var reloaded = new GrainState(new()); + await provider.ReadStateAsync("profile/preferences", id, reloaded); + reloaded.State!.Value.ShouldBe("Привіт"); + await provider.ClearStateAsync("profile/preferences", id, reloaded); + } + + [Fact] + public async Task CorruptPersistentBytes_PropagateReadWriteAndClearFailures() + { + await using var session = await LocalVfsSession.CreateAsync(); + (await session.Storage.UploadAsync("invalid serialized payload", new UploadOptions("corrupt.state"), TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var provider = CreateProvider(session.Storage, CreateOptions("corrupt.state")); + var id = GrainId.Create("coverage", "corrupt"); + var state = new GrainState(new()); + await Should.ThrowAsync(() => provider.ReadStateAsync("profile", id, state)); + await Should.ThrowAsync(() => provider.WriteStateAsync("profile", id, state)); + await Should.ThrowAsync(() => provider.ClearStateAsync("profile", id, state)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task InvalidStoragePath_PropagatesFailureInsteadOfResettingOrAcknowledgingState(int operation) + { + await using var session = await LocalVfsSession.CreateAsync(); + var provider = CreateProvider(session.Storage, CreateOptions("../outside.state")); + var id = GrainId.Create("coverage", "invalid-path"); + var state = new GrainState(new()); + await Should.ThrowAsync(() => operation switch + { + 0 => provider.ReadStateAsync("profile", id, state), + 1 => provider.WriteStateAsync("profile", id, state), + _ => provider.ClearStateAsync("profile", id, state) + }); + state.RecordExists.ShouldBeFalse(); + } + + private ManagedCodeStorageGrainStorageOptions CreateOptions(string path) + { + var configured = fixture.GetSiloServiceProvider().GetRequiredService>().Get(ManagedCodeOrleansProviderNames.FileSystem); + return new() { GrainStorageSerializer = configured.GrainStorageSerializer, PathBuilder = _ => path }; + } + + private ManagedCodeGrainStorage CreateProvider(IStorage storage, ManagedCodeStorageGrainStorageOptions options) => new( + "coverage", options, storage, fixture.GetSiloServiceProvider().GetRequiredService(), NullLogger.Instance); +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStoredRecordSerializationContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStoredRecordSerializationContractsTests.cs new file mode 100644 index 0000000..a1a0404 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Orleans/OrleansStoredRecordSerializationContractsTests.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Orleans.Storage; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Orleans; + +public sealed class OrleansStoredRecordSerializationContractsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RecordExists_RoundTripsWithDefaultSuppressionEnabled(bool exists) + { + var options = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; + var record = new ManagedCodeStoredGrainState { State = "value", RecordExists = exists }; + JsonSerializer.Deserialize>(JsonSerializer.Serialize(record, options), options)!.RecordExists.ShouldBe(exists); + } + + [Fact] + public void LegacyPositiveRecord_WithoutAnExplicitFlagRetainsItsInterpretation() + { + JsonSerializer.Deserialize>("{\"State\":\"legacy\"}")!.RecordExists.ShouldBeTrue(); + new ManagedCodeStoredGrainState().RecordExists.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/AwsRegistrationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/AwsRegistrationTests.cs new file mode 100644 index 0000000..12f3dc2 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/AwsRegistrationTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ManagedCode.Storage.Aws; +using ManagedCode.Storage.Aws.Extensions; +using ManagedCode.Storage.Aws.Options; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class AwsRegistrationTests(AwsRegistrationHost host) : IClassFixture +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task RegistrationOverloads_ResolveProvidersThatPersistBytes(int registration) + { + var bucket = "registration-" + Guid.NewGuid().ToString("N"); + void Configure(AWSStorageOptions target) + { + target.Bucket = bucket; + target.PublicKey = "localkey"; target.SecretKey = "localsecret"; + target.OriginalOptions = new Amazon.S3.AmazonS3Config { ServiceURL = host.Endpoint }; + } + var services = new ServiceCollection(); + services.AddLogging(); + var options = new AWSStorageOptions(); + Configure(options); + switch (registration) + { + case 0: services.AddAWSStorage(options); break; + case 1: services.AddAWSStorage(Configure); break; + case 2: services.AddAWSStorageAsDefault(options); break; + case 3: services.AddAWSStorageAsDefault(Configure); break; + case 4: services.AddAWSStorage("storage", Configure); break; + case 5: services.AddAWSStorageAsDefault("storage", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + var storage = registration >= 4 + ? provider.GetRequiredKeyedService("storage") + : provider.GetRequiredService(); + await RegistrationRoundTrip.VerifyAsync(storage); + if (registration is 2 or 3) await RegistrationRoundTrip.VerifyAsync(provider.GetRequiredService()); + if (registration == 5) provider.GetRequiredKeyedService("storage").ShouldBeSameAs(storage); + (await storage.RemoveContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/AzureRegistrationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/AzureRegistrationTests.cs new file mode 100644 index 0000000..3ef909b --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/AzureRegistrationTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ManagedCode.Storage.Azure; +using ManagedCode.Storage.Azure.Extensions; +using ManagedCode.Storage.Azure.Options; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class AzureRegistrationTests(AzureRegistrationHost host) : IClassFixture +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task RegistrationOverloads_ResolveProvidersThatPersistBytes(int registration) + { + var bucket = "registration-" + Guid.NewGuid().ToString("N"); + void Configure(AzureStorageOptions target) + { + target.Container = bucket; target.ConnectionString = host.ConnectionString; + } + var services = new ServiceCollection(); + services.AddLogging(); + var options = new AzureStorageOptions(); + Configure(options); + switch (registration) + { + case 0: services.AddAzureStorage(options); break; + case 1: services.AddAzureStorage(Configure); break; + case 2: services.AddAzureStorageAsDefault(options); break; + case 3: services.AddAzureStorageAsDefault(Configure); break; + case 4: services.AddAzureStorage("storage", Configure); break; + case 5: services.AddAzureStorageAsDefault("storage", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + var storage = registration >= 4 + ? provider.GetRequiredKeyedService("storage") + : provider.GetRequiredService(); + await RegistrationRoundTrip.VerifyAsync(storage); + if (registration is 2 or 3) await RegistrationRoundTrip.VerifyAsync(provider.GetRequiredService()); + if (registration == 5) provider.GetRequiredKeyedService("storage").ShouldBeSameAs(storage); + (await storage.RemoveContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/GcsRegistrationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/GcsRegistrationTests.cs new file mode 100644 index 0000000..1f3b03b --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/GcsRegistrationTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ManagedCode.Storage.Google; +using ManagedCode.Storage.Google.Extensions; +using ManagedCode.Storage.Google.Options; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class GcsRegistrationTests(GcsRegistrationHost host) : IClassFixture +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task RegistrationOverloads_ResolveProvidersThatPersistBytes(int registration) + { + var bucket = "registration-" + Guid.NewGuid().ToString("N"); + void Configure(GCPStorageOptions target) + { + target.BucketOptions = new BucketOptions { Bucket = bucket, ProjectId = "registration-contract" }; + target.StorageClientBuilder = new global::Google.Cloud.Storage.V1.StorageClientBuilder { UnauthenticatedAccess = true, BaseUri = host.Endpoint }; + } + var services = new ServiceCollection(); + services.AddLogging(); + var options = new GCPStorageOptions(); + Configure(options); + switch (registration) + { + case 0: services.AddGCPStorage(options); break; + case 1: services.AddGCPStorage(Configure); break; + case 2: services.AddGCPStorageAsDefault(options); break; + case 3: services.AddGCPStorageAsDefault(Configure); break; + case 4: services.AddGCPStorage("storage", Configure); break; + case 5: services.AddGCPStorageAsDefault("storage", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + var storage = registration >= 4 + ? provider.GetRequiredKeyedService("storage") + : provider.GetRequiredService(); + await RegistrationRoundTrip.VerifyAsync(storage); + if (registration is 2 or 3) await RegistrationRoundTrip.VerifyAsync(provider.GetRequiredService()); + if (registration == 5) provider.GetRequiredKeyedService("storage").ShouldBeSameAs(storage); + (await storage.RemoveContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/ProviderRegistrationValidationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/ProviderRegistrationValidationTests.cs new file mode 100644 index 0000000..d05e01a --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/ProviderRegistrationValidationTests.cs @@ -0,0 +1,74 @@ +using Amazon.S3; +using Google.Cloud.Storage.V1; +using ManagedCode.Storage.Aws.Extensions; +using ManagedCode.Storage.Aws.Options; +using ManagedCode.Storage.Azure.Extensions; +using ManagedCode.Storage.Azure.Options; +using ManagedCode.Storage.Core.Exceptions; +using ManagedCode.Storage.Google.Extensions; +using ManagedCode.Storage.Google.Options; +using ManagedCode.Storage.Sftp.Extensions; +using ManagedCode.Storage.Sftp.Options; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class ProviderRegistrationValidationTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void AwsRegistration_RejectsMissingBucketOrCredentials(int invalid) + { + var options = new AWSStorageOptions { Bucket = "contract", PublicKey = "localkey", SecretKey = "localsecret", OriginalOptions = new AmazonS3Config() }; + if (invalid == 0) options.Bucket = ""; + if (invalid == 1) options.PublicKey = ""; + if (invalid == 2) options.SecretKey = ""; + Should.Throw(() => new ServiceCollection().AddAWSStorage(options)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void AzureRegistration_RejectsMissingContainerOrConnection(int invalid) + { + var options = new AzureStorageOptions { Container = "contract", ConnectionString = "UseDevelopmentStorage=true" }; + if (invalid == 0) options.Container = ""; else options.ConnectionString = ""; + Should.Throw(() => new ServiceCollection().AddAzureStorage(options)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void GcsRegistration_RejectsMissingClientBucketOrProject(int invalid) + { + var options = new GCPStorageOptions + { + BucketOptions = new BucketOptions { Bucket = "contract", ProjectId = "contract" }, + StorageClientBuilder = new StorageClientBuilder { UnauthenticatedAccess = true } + }; + if (invalid == 0) options.StorageClientBuilder = null; + if (invalid == 1) options.BucketOptions.Bucket = ""; + if (invalid == 2) options.BucketOptions.ProjectId = ""; + Should.Throw(() => new ServiceCollection().AddGCPStorage(options)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void SftpRegistration_RejectsInvalidConnectionSettings(int invalid) + { + var options = new SftpStorageOptions { Host = "127.0.0.1", Port = 22, Username = "test", Password = "test" }; + if (invalid == 0) options.Host = " "; + if (invalid == 1) options.Port = 0; + if (invalid == 2) options.Username = ""; + if (invalid == 3) options.Password = null; + Should.Throw(() => new ServiceCollection().AddSftpStorage(options)); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationHosts.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationHosts.cs new file mode 100644 index 0000000..838d3a4 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationHosts.cs @@ -0,0 +1,42 @@ +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.Tests.Storages.AWS; +using ManagedCode.Storage.Tests.Storages.Azure; +using ManagedCode.Storage.Tests.Storages.GCS; +using ManagedCode.Storage.Tests.Storages.Sftp; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.Azurite; +using Testcontainers.FakeGcsServer; +using Testcontainers.LocalStack; +using Testcontainers.Sftp; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class AwsRegistrationHost : BaseContainer +{ + public string Endpoint => Container.GetConnectionString(); + protected override LocalStackContainer Build() => AwsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => AWSConfigurator.ConfigureServices(Endpoint); +} + +public sealed class AzureRegistrationHost : BaseContainer +{ + public string ConnectionString => Container.GetConnectionString(); + protected override AzuriteContainer Build() => new AzuriteBuilder(ContainerImages.Azurite).WithCommand("--skipApiVersionCheck").Build(); + protected override ServiceProvider ConfigureServices() => AzureConfigurator.ConfigureServices(ConnectionString); +} + +public sealed class GcsRegistrationHost : BaseContainer +{ + public string Endpoint => Container.GetConnectionString(); + protected override FakeGcsServerContainer Build() => GcsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => GCSConfigurator.ConfigureServices(Endpoint); +} + +public sealed class SftpRegistrationHost : BaseContainer +{ + public string Host => Container.GetHost(); + public int Port => Container.GetPort(); + protected override SftpContainer Build() => SftpContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => SftpConfigurator.ConfigureServices(Host, Port, + SftpContainerFactory.Username, SftpContainerFactory.Password, SftpContainerFactory.RemoteDirectory); +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationRoundTrip.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationRoundTrip.cs new file mode 100644 index 0000000..6f09ddc --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/RegistrationRoundTrip.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +internal static class RegistrationRoundTrip +{ + public static async Task VerifyAsync(IStorage storage) + { + var token = TestContext.Current.CancellationToken; + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + var name = Guid.NewGuid().ToString("N") + ".bin"; + var bytes = Encoding.UTF8.GetBytes("Registered provider: Привіт, storage!"); + (await storage.UploadAsync(bytes, new UploadOptions { FileName = name }, token)).IsSuccess.ShouldBeTrue(); + var opened = await storage.GetStreamAsync(name, token); + opened.IsSuccess.ShouldBeTrue(); + await using var stream = opened.Value!; + using var output = new MemoryStream(); + await stream.CopyToAsync(output, token); + output.ToArray().ShouldBe(bytes); + (await storage.DeleteAsync(name, token)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Registration/SftpRegistrationTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Registration/SftpRegistrationTests.cs new file mode 100644 index 0000000..64cd6f5 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Registration/SftpRegistrationTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ManagedCode.Storage.Sftp; +using ManagedCode.Storage.Sftp.Extensions; +using ManagedCode.Storage.Sftp.Options; + +namespace ManagedCode.Storage.Tests.Storages.Registration; + +public sealed class SftpRegistrationTests(SftpRegistrationHost host) : IClassFixture +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task RegistrationOverloads_ResolveProvidersThatPersistBytes(int registration) + { + var bucket = "registration-" + Guid.NewGuid().ToString("N"); + void Configure(SftpStorageOptions target) + { + target.Host = host.Host; target.Port = host.Port; + target.Username = ManagedCode.Storage.Tests.Storages.Sftp.SftpContainerFactory.Username; + target.Password = ManagedCode.Storage.Tests.Storages.Sftp.SftpContainerFactory.Password; + target.RemoteDirectory = ManagedCode.Storage.Tests.Storages.Sftp.SftpContainerFactory.RemoteDirectory + "/" + bucket; + } + var services = new ServiceCollection(); + services.AddLogging(); + var options = new SftpStorageOptions(); + Configure(options); + switch (registration) + { + case 0: services.AddSftpStorage(options); break; + case 1: services.AddSftpStorage(Configure); break; + case 2: services.AddSftpStorageAsDefault(options); break; + case 3: services.AddSftpStorageAsDefault(Configure); break; + case 4: services.AddSftpStorage("storage", Configure); break; + case 5: services.AddSftpStorageAsDefault("storage", Configure); break; + } + await using var provider = services.BuildServiceProvider(); + var storage = registration >= 4 + ? provider.GetRequiredKeyedService("storage") + : provider.GetRequiredService(); + await RegistrationRoundTrip.VerifyAsync(storage); + if (registration is 2 or 3) await RegistrationRoundTrip.VerifyAsync(provider.GetRequiredService()); + if (registration == 5) provider.GetRequiredKeyedService("storage").ShouldBeSameAs(storage); + (await storage.RemoveContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/RemoteVfsRangeProviders.cs b/Tests/ManagedCode.Storage.Tests/Storages/RemoteVfsRangeProviders.cs new file mode 100644 index 0000000..2f72c21 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/RemoteVfsRangeProviders.cs @@ -0,0 +1,38 @@ +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.Tests.Storages.Abstracts; +using ManagedCode.Storage.Tests.Storages.AWS; +using ManagedCode.Storage.Tests.Storages.Azure; +using ManagedCode.Storage.Tests.Storages.GCS; +using ManagedCode.Storage.Tests.Storages.Sftp; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.Azurite; +using Testcontainers.FakeGcsServer; +using Testcontainers.LocalStack; +using Testcontainers.Sftp; + +namespace ManagedCode.Storage.Tests.Storages; + +public sealed class AwsRemoteVfsRangeTests : RemoteVfsRangeContracts +{ + protected override LocalStackContainer Build() => AwsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => AWSConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class AzureRemoteVfsRangeTests : RemoteVfsRangeContracts +{ + protected override AzuriteContainer Build() => new AzuriteBuilder(ContainerImages.Azurite).WithCommand("--skipApiVersionCheck").Build(); + protected override ServiceProvider ConfigureServices() => AzureConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class GcsRemoteVfsRangeTests : RemoteVfsRangeContracts +{ + protected override FakeGcsServerContainer Build() => GcsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => GCSConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class SftpRemoteVfsRangeTests : RemoteVfsRangeContracts +{ + protected override SftpContainer Build() => SftpContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => SftpConfigurator.ConfigureServices(Container.GetHost(), Container.GetPort(), + SftpContainerFactory.Username, SftpContainerFactory.Password, SftpContainerFactory.RemoteDirectory); +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpCancellationContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpCancellationContractsTests.cs new file mode 100644 index 0000000..9b6cd29 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpCancellationContractsTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.Tests.Storages.Sftp; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Testcontainers.Sftp; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Sftp; + +public sealed class SftpCancellationContractsTests : BaseContainer +{ + protected override SftpContainer Build() => SftpContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => SftpConfigurator.ConfigureServices( + Container.GetHost(), Container.GetPort(), SftpContainerFactory.Username, + SftpContainerFactory.Password, SftpContainerFactory.RemoteDirectory); + + [Fact] + public async Task CancellationDuringFileRead_StopsTheActualSftpUpload() + { + var token = TestContext.Current.CancellationToken; + (await Storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + await using var file = await LargeFileTestHelper.CreateRandomFileAsync(16 * 1024 * 1024, cancellationToken: token); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + await using var stream = new CancelAfterReadFileStream(file.FilePath, cancellation); + var uploaded = await Storage.UploadAsync(stream, new UploadOptions { FileName = "canceled.bin" }, cancellation.Token); + uploaded.IsFailed.ShouldBeTrue(); + stream.BytesRead.ShouldBeGreaterThan(0); + stream.BytesRead.ShouldBeLessThan(file.FileInfo.Length); + var metadata = await Storage.GetBlobMetadataAsync("canceled.bin", token); + if (metadata.IsSuccess) metadata.Value!.Length.ShouldBeLessThan((ulong)file.FileInfo.Length); + } + + // Real file input with a deterministic cancellation boundary; the SFTP SDK and server remain real. + private sealed class CancelAfterReadFileStream(string path, CancellationTokenSource cancellation) + : FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read) + { + public long BytesRead { get; private set; } + private void AfterRead(int count) { BytesRead += count; if (count > 0) cancellation.Cancel(); } + public override int Read(byte[] buffer, int offset, int count) + { var read = base.Read(buffer, offset, count); AfterRead(read); return read; } + public override int Read(Span buffer) + { var read = base.Read(buffer); AfterRead(read); return read; } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { var read = await base.ReadAsync(buffer, cancellationToken); AfterRead(read); return read; } + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { var read = await base.ReadAsync(buffer, offset, count, cancellationToken); AfterRead(read); return read; } + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpChrootRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpChrootRuntimeContractsTests.cs new file mode 100644 index 0000000..6beeea2 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpChrootRuntimeContractsTests.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Sftp; +using ManagedCode.Storage.Sftp.Options; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Sftp; + +public sealed class SftpChrootRuntimeContractsTests +{ + [Fact] + public async Task ChrootCleanup_ReportsTheActualPermissionFailureAndPreservesTheRoot() + { + await using var container = SftpContainerFactory.Create(); + var token = TestContext.Current.CancellationToken; + await container.StartAsync(token); + using var storage = new SftpStorage(new SftpStorageOptions + { + Host = container.GetHost(), + Port = container.GetPort(), + Username = SftpContainerFactory.Username, + Password = SftpContainerFactory.Password, + RemoteDirectory = "/" + }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + (await storage.UploadAsync("nested", new UploadOptions { Directory = "upload/nested/child", FileName = "data.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await storage.UploadAsync("root file", new UploadOptions { Directory = "upload", FileName = "top.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await storage.RemoveContainerAsync(token)).IsFailed.ShouldBeTrue(); + storage.StorageClient.Exists("/").ShouldBeTrue(); + storage.StorageClient.Exists("/upload").ShouldBeTrue(); + storage.StorageClient.Exists("/upload/nested/child/data.txt").ShouldBeFalse(); + storage.StorageClient.Exists("/upload/top.txt").ShouldBeFalse(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerFactory.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerFactory.cs index 15349a3..1594a27 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerFactory.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerFactory.cs @@ -7,14 +7,15 @@ internal static class SftpContainerFactory { public const string Username = "storage"; public const string Password = "storage-password"; - public const string RemoteDirectory = "/upload"; + public const string UploadMountDirectory = "/upload"; + public const string RemoteDirectory = UploadMountDirectory + "/storage"; public static SftpContainer Create() { return new SftpBuilder(ContainerImages.Sftp) .WithUsername(Username) .WithPassword(Password) - .WithUploadDirectory(RemoteDirectory) + .WithUploadDirectory(UploadMountDirectory) .WithCleanUp(true) .Build(); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerTests.cs index 40278b8..f7fc87e 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpContainerTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; using Testcontainers.Sftp; +using Shouldly; + namespace ManagedCode.Storage.Tests.Storages.Sftp; /// @@ -9,6 +11,53 @@ namespace ManagedCode.Storage.Tests.Storages.Sftp; /// public class SftpContainerTests : ContainerTests { + [Xunit.Fact] + public async System.Threading.Tasks.Task RemoveLogicalContainer_DeletesTheDirectoryAndPreservesTheChrootUploadMount() + { + var token = Xunit.TestContext.Current.CancellationToken; + var client = ((ManagedCode.Storage.Sftp.SftpStorage)Storage).StorageClient; + (await Storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + (await Storage.UploadAsync("nested", o => { o.Directory = "child"; o.FileName = "proof.txt"; }, token)).IsSuccess.ShouldBeTrue(); + (await Storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + client.Exists(SftpContainerFactory.RemoteDirectory).ShouldBeFalse(); + client.Exists("/upload").ShouldBeTrue(); + } + + [Xunit.Theory] + [Xunit.InlineData(true)] + [Xunit.InlineData(false)] + public async System.Threading.Tasks.Task NestedContainerCreation_CreatesAncestorsAndRejectsAnExistingFile(bool autoCreate) + { + var token = Xunit.TestContext.Current.CancellationToken; + var root = SftpContainerFactory.RemoteDirectory + "/parents-" + System.Guid.NewGuid().ToString("N") + "/nested/container"; + var options = new ManagedCode.Storage.Sftp.Options.SftpStorageOptions + { + Host = Container.GetHost(), + Port = Container.GetPort(), + Username = SftpContainerFactory.Username, + Password = SftpContainerFactory.Password, + RemoteDirectory = root, + CreateContainerIfNotExists = autoCreate + }; + using var storage = new ManagedCode.Storage.Sftp.SftpStorage(options, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + storage.StorageClient.GetAttributes(root).IsDirectory.ShouldBeTrue(); + (await storage.UploadAsync("stored", new ManagedCode.Storage.Core.Models.UploadOptions("proof.txt"), token)).IsSuccess.ShouldBeTrue(); + storage.StorageClient.ReadAllText(root + "/proof.txt").ShouldBe("stored"); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + storage.StorageClient.Exists(root).ShouldBeFalse(); + + (await Storage.UploadAsync("file blocker", new ManagedCode.Storage.Core.Models.UploadOptions("blocker.bin"), token)).IsSuccess.ShouldBeTrue(); + options.RemoteDirectory = SftpContainerFactory.RemoteDirectory + "/blocker.bin"; + using var blocked = new ManagedCode.Storage.Sftp.SftpStorage(options, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + (await blocked.CreateContainerAsync(token)).IsFailed.ShouldBeTrue(); + blocked.StorageClient.GetAttributes(options.RemoteDirectory).IsRegularFile.ShouldBeTrue(); + blocked.StorageClient.ReadAllText(options.RemoteDirectory).ShouldBe("file blocker"); + } + protected override SftpContainer Build() => SftpContainerFactory.Create(); protected override ServiceProvider ConfigureServices() diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpPrivateKeyRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpPrivateKeyRuntimeContractsTests.cs new file mode 100644 index 0000000..8d7ce91 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpPrivateKeyRuntimeContractsTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.Core.Providers; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Extensions; +using ManagedCode.Storage.Sftp; +using ManagedCode.Storage.Sftp.Extensions; +using ManagedCode.Storage.Sftp.Options; +using ManagedCode.Storage.Tests.Common; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Testcontainers.Sftp; +using Xunit; + +namespace ManagedCode.Storage.Tests.Storages.Sftp; + +public sealed class SftpPrivateKeyRuntimeContractsTests : IAsyncLifetime +{ + private string _directory = null!; + private string _privateKey = null!; + private SftpContainer _container = null!; + + public async ValueTask InitializeAsync() + { + _directory = Path.Combine(Path.GetTempPath(), "storage-ephemeral-ssh-key", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + using var rsa = RSA.Create(2048); + _privateKey = rsa.ExportRSAPrivateKeyPem(); + await File.WriteAllTextAsync(Path.Combine(_directory, "private.pem"), _privateKey, TestContext.Current.CancellationToken); + var key = rsa.ExportParameters(false); + using var wire = new MemoryStream(); + WriteSshField(wire, Encoding.ASCII.GetBytes("ssh-rsa")); + WriteSshField(wire, key.Exponent!); + WriteSshField(wire, new byte[] { 0 }.Concat(key.Modulus!).ToArray()); + var publicFile = Path.Combine(_directory, "public.pub"); + await File.WriteAllTextAsync(publicFile, "ssh-rsa " + Convert.ToBase64String(wire.ToArray()), TestContext.Current.CancellationToken); + _container = new SftpBuilder(ContainerImages.Sftp).WithUsername(SftpContainerFactory.Username).WithPassword(SftpContainerFactory.Password) + .WithUploadDirectory(SftpContainerFactory.UploadMountDirectory).WithResourceMapping(new FileInfo(publicFile), "/home/storage/.ssh/keys/").Build(); + await _container.StartAsync(TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task RealPasswordAndPrivateKeyFactories_RoundTripAndRecursivelyRemoveTheirContainer(int mode) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddStorageFactory(); + services.AddSftpStorage(options => { options.Host = _container.GetHost(); options.Port = _container.GetPort(); options.Username = SftpContainerFactory.Username; options.Password = SftpContainerFactory.Password; }); + await using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var directory = "/upload/keys-" + Guid.NewGuid().ToString("N"); + var storage = mode switch + { + 0 => factory.CreateSftpStorageWithPrivateKey(_container.GetHost(), SftpContainerFactory.Username, Path.Combine(_directory, "private.pem"), port: _container.GetPort(), remoteDirectory: directory), + 1 => factory.CreateSftpStorageWithPrivateKeyContent(_container.GetHost(), SftpContainerFactory.Username, _privateKey, port: _container.GetPort(), remoteDirectory: directory), + _ => factory.CreateSftpStorageWithPassword(_container.GetHost(), SftpContainerFactory.Username, SftpContainerFactory.Password, _container.GetPort(), directory) + }; + var token = TestContext.Current.CancellationToken; + (await storage.CreateContainerAsync(token)).IsSuccess.ShouldBeTrue(); + var uploaded = await storage.UploadAsync("actual SSH authenticated bytes", new UploadOptions { Directory = "nested/child", FileName = "content.txt" }, token); + uploaded.IsSuccess.ShouldBeTrue(); + var result = await storage.DownloadAsync("nested/child/content.txt", token); + result.IsSuccess.ShouldBeTrue(); + await using (var file = result.Value!) (await File.ReadAllTextAsync(file.FilePath, token)).ShouldBe("actual SSH authenticated bytes"); + var defaults = (SftpStorageOptions)provider.GetServices().Single(p => p is SftpStorageProvider).GetDefaultOptions(); + defaults.Host.ShouldBe(_container.GetHost()); + defaults.Password.ShouldBe(SftpContainerFactory.Password); + (await storage.RemoveContainerAsync(token)).IsSuccess.ShouldBeTrue(); + ((SftpStorage)storage).StorageClient.Exists(directory).ShouldBeFalse(); + ((SftpStorage)storage).StorageClient.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) await _container.DisposeAsync(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, true); + GC.SuppressFinalize(this); + } + + private static void WriteSshField(Stream stream, byte[] field) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, field.Length); + stream.Write(length); + stream.Write(field); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpSpecificTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpSpecificTests.cs index ab88408..9b852a2 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpSpecificTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpSpecificTests.cs @@ -34,7 +34,7 @@ protected override ServiceProvider ConfigureServices() public async Task TestConnectionAsync_ShouldReturnSuccess() { var storage = ServiceProvider.GetRequiredService(); - var result = await storage.TestConnectionAsync(); + var result = await storage.TestConnectionAsync(TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBeTrue(); } @@ -43,7 +43,7 @@ public async Task TestConnectionAsync_ShouldReturnSuccess() public async Task GetWorkingDirectoryAsync_ShouldReturnDirectory() { var storage = ServiceProvider.GetRequiredService(); - var result = await storage.GetWorkingDirectoryAsync(); + var result = await storage.GetWorkingDirectoryAsync(TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldNotBeNullOrEmpty(); } @@ -52,7 +52,8 @@ public async Task GetWorkingDirectoryAsync_ShouldReturnDirectory() public async Task ChangeWorkingDirectoryAsync_ShouldSucceed() { var storage = ServiceProvider.GetRequiredService(); - var result = await storage.ChangeWorkingDirectoryAsync(SftpContainerFactory.RemoteDirectory); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var result = await storage.ChangeWorkingDirectoryAsync(SftpContainerFactory.RemoteDirectory, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); } @@ -64,20 +65,20 @@ public async Task UploadAndDownloadUsingStreams_ShouldMatch() var content = "Stream based upload"; await using var uploadStream = new MemoryStream(Encoding.UTF8.GetBytes(content)); - var writeResult = await storage.OpenWriteStreamAsync(fileName); + var writeResult = await storage.OpenWriteStreamAsync(fileName, TestContext.Current.CancellationToken); writeResult.IsSuccess.ShouldBeTrue(); var destinationStream = writeResult.Value ?? throw new InvalidOperationException("Write stream is null"); await using (destinationStream) { - await uploadStream.CopyToAsync(destinationStream); + await uploadStream.CopyToAsync(destinationStream, TestContext.Current.CancellationToken); } - var readResult = await storage.OpenReadStreamAsync(fileName); + var readResult = await storage.OpenReadStreamAsync(fileName, TestContext.Current.CancellationToken); readResult.IsSuccess.ShouldBeTrue(); var sourceStream = readResult.Value ?? throw new InvalidOperationException("Read stream is null"); using var reader = new StreamReader(sourceStream); - var downloadedContent = await reader.ReadToEndAsync(); + var downloadedContent = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); downloadedContent.ShouldBe(content); } @@ -88,11 +89,11 @@ public async Task UploadFile_ShouldAppearInListing() var storage = ServiceProvider.GetRequiredService(); var fileName = "list-test.txt"; - var uploadResult = await storage.UploadAsync("List test", options => options.FileName = fileName); + var uploadResult = await storage.UploadAsync("List test", options => options.FileName = fileName, TestContext.Current.CancellationToken); uploadResult.IsSuccess.ShouldBeTrue(); var found = false; - await foreach (var item in storage.GetBlobMetadataListAsync()) + await foreach (var item in storage.GetBlobMetadataListAsync(cancellationToken: TestContext.Current.CancellationToken)) { if (item.Name == fileName) { @@ -115,16 +116,16 @@ await storage.UploadAsync("Hello", options => { options.FileName = fileName; options.Directory = directory; - }); + }, TestContext.Current.CancellationToken); - var deleteResult = await storage.DeleteDirectoryAsync(directory); + var deleteResult = await storage.DeleteDirectoryAsync(directory, TestContext.Current.CancellationToken); deleteResult.IsSuccess.ShouldBeTrue(); var existsResult = await storage.ExistsAsync(new ExistOptions { Directory = directory, FileName = fileName - }); + }, TestContext.Current.CancellationToken); existsResult.IsSuccess.ShouldBeTrue(); existsResult.Value.ShouldBeFalse(); @@ -138,7 +139,7 @@ public async Task UploadLargeFile_ShouldSucceed() var bytes = new byte[1024 * 1024]; new Random().NextBytes(bytes); - var result = await storage.UploadAsync(bytes, options => options.FileName = fileName); + var result = await storage.UploadAsync(bytes, options => options.FileName = fileName, TestContext.Current.CancellationToken); result.IsSuccess.ShouldBeTrue(); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpUploadTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpUploadTests.cs index 76b389c..454b081 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpUploadTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Sftp/SftpUploadTests.cs @@ -23,10 +23,9 @@ protected override ServiceProvider ConfigureServices() SftpContainerFactory.RemoteDirectory); } - [Fact(Skip = "Cancellation not working reliably with containerized SFTP server - uploads complete too quickly to cancel")] + [Fact] public override async Task UploadAsync_WithCancellationToken_BigFile_ShouldCancel() { - // This method is skipped - the containerized SFTP server completes uploads too quickly to be cancelled effectively - await Task.CompletedTask; + await base.UploadAsync_WithCancellationToken_BigFile_ShouldCancel(); } } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/UnavailableProviders.cs b/Tests/ManagedCode.Storage.Tests/Storages/UnavailableProviders.cs new file mode 100644 index 0000000..230f3a7 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/Storages/UnavailableProviders.cs @@ -0,0 +1,38 @@ +using ManagedCode.Storage.Tests.Common; +using ManagedCode.Storage.Tests.Storages.Abstracts; +using ManagedCode.Storage.Tests.Storages.AWS; +using ManagedCode.Storage.Tests.Storages.Azure; +using ManagedCode.Storage.Tests.Storages.GCS; +using ManagedCode.Storage.Tests.Storages.Sftp; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.Azurite; +using Testcontainers.FakeGcsServer; +using Testcontainers.LocalStack; +using Testcontainers.Sftp; + +namespace ManagedCode.Storage.Tests.Storages; + +public sealed class AwsUnavailableStorageTests : UnavailableStorageContracts +{ + protected override LocalStackContainer Build() => AwsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => AWSConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class AzureUnavailableStorageTests : UnavailableStorageContracts +{ + protected override AzuriteContainer Build() => new AzuriteBuilder(ContainerImages.Azurite).WithCommand("--skipApiVersionCheck").Build(); + protected override ServiceProvider ConfigureServices() => AzureConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class GcsUnavailableStorageTests : UnavailableStorageContracts +{ + protected override FakeGcsServerContainer Build() => GcsContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => GCSConfigurator.ConfigureServices(Container.GetConnectionString()); +} + +public sealed class SftpUnavailableStorageTests : UnavailableStorageContracts +{ + protected override SftpContainer Build() => SftpContainerFactory.Create(); + protected override ServiceProvider ConfigureServices() => SftpConfigurator.ConfigureServices(Container.GetHost(), Container.GetPort(), + SftpContainerFactory.Username, SftpContainerFactory.Password, SftpContainerFactory.RemoteDirectory); +} diff --git a/Tests/ManagedCode.Storage.Tests/Storages/VerifiedContentSnapshotTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/VerifiedContentSnapshotTests.cs index 2df1689..cbd4ec2 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/VerifiedContentSnapshotTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/VerifiedContentSnapshotTests.cs @@ -18,13 +18,13 @@ public async Task CreateAsync_ReturnsSeekableSnapshotOfExactContent() await using var source = new MemoryStream(bytes); var digest = Convert.ToHexStringLower(SHA256.HashData(bytes)); - await using var snapshot = await VerifiedContentSnapshot.CreateAsync(source, bytes.Length, digest); + await using var snapshot = await VerifiedContentSnapshot.CreateAsync(source, bytes.Length, digest, TestContext.Current.CancellationToken); snapshot.ShouldNotBeNull(); snapshot!.CanSeek.ShouldBeTrue(); snapshot.Position.ShouldBe(0); using var result = new MemoryStream(); - await snapshot.CopyToAsync(result); + await snapshot.CopyToAsync(result, TestContext.Current.CancellationToken); result.ToArray().ShouldBe(bytes); } @@ -37,7 +37,7 @@ public async Task CreateAsync_RejectsWrongLengthOrDigest(long expectedLength, st await using var source = new MemoryStream(Encoding.UTF8.GetBytes("ab")); var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(digestSource))); - var snapshot = await VerifiedContentSnapshot.CreateAsync(source, expectedLength, digest); + var snapshot = await VerifiedContentSnapshot.CreateAsync(source, expectedLength, digest, TestContext.Current.CancellationToken); snapshot.ShouldBeNull(); } diff --git a/Tests/ManagedCode.Storage.Tests/Storages/VerifiedObjectUploadBytesTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/VerifiedObjectUploadBytesTests.cs index 9bf89a4..36323e1 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/VerifiedObjectUploadBytesTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/VerifiedObjectUploadBytesTests.cs @@ -23,7 +23,7 @@ public async Task LostWriteResponse_ReconcilesOnlyExactStoredBytesAndMetadata() }; var bytes = Encoding.UTF8.GetBytes("{\"value\":1}"); - var result = await storage.WriteBytesIfAbsentOrSameAsync("payload.json", bytes, options); + var result = await storage.WriteBytesIfAbsentOrSameAsync("payload.json", bytes, options, cancellationToken: TestContext.Current.CancellationToken); result.ReusedExisting.ShouldBeTrue(); result.Info.Length.ShouldBe(bytes.Length); diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/FileSystemVirtualFileSystemUnicodeMountTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/FileSystemVirtualFileSystemUnicodeMountTests.cs index 86d6e15..b86de0f 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/FileSystemVirtualFileSystemUnicodeMountTests.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/FileSystemVirtualFileSystemUnicodeMountTests.cs @@ -27,7 +27,7 @@ public async Task MountingExistingUnicodeDirectories_ShouldExposeFiles( Directory.CreateDirectory(internationalFolder); var seededFilePath = Path.Combine(internationalFolder, $"{fileName}.txt"); - await File.WriteAllTextAsync(seededFilePath, content); + await File.WriteAllTextAsync(seededFilePath, content, TestContext.Current.CancellationToken); var options = new FileSystemStorageOptions { @@ -62,10 +62,10 @@ async ValueTask Cleanup() var vfs = context.FileSystem; var expectedPath = new VfsPath($"/international/{directoryName}/{fileName}.txt"); - (await vfs.FileExistsAsync(expectedPath)).ShouldBeTrue(); + (await vfs.FileExistsAsync(expectedPath, TestContext.Current.CancellationToken)).ShouldBeTrue(); - var file = await vfs.GetFileAsync(expectedPath); - var actualContent = await file.ReadAllTextAsync(); + var file = await vfs.GetFileAsync(expectedPath, TestContext.Current.CancellationToken); + var actualContent = await file.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken); actualContent.ShouldBe(content); var entries = new List(); @@ -74,7 +74,7 @@ async ValueTask Cleanup() IncludeDirectories = false, IncludeFiles = true, Recursive = false - })) + }, TestContext.Current.CancellationToken)) { entries.Add(entry); } diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AwsVirtualFileSystemFixture.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AwsVirtualFileSystemFixture.cs index 59fda3e..ee9e777 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AwsVirtualFileSystemFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AwsVirtualFileSystemFixture.cs @@ -24,13 +24,13 @@ public sealed class AwsVirtualFileSystemFixture : IVirtualFileSystemFixture, IAs SupportsMove: false, SupportsDirectoryStats: false); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _container = AwsContainerFactory.Create(); await _container.StartAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container is not null) { diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AzureVirtualFileSystemFixture.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AzureVirtualFileSystemFixture.cs index 2e236fb..bd2b0a0 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AzureVirtualFileSystemFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/AzureVirtualFileSystemFixture.cs @@ -16,7 +16,7 @@ public sealed class AzureVirtualFileSystemFixture : IVirtualFileSystemFixture, I public VirtualFileSystemCapabilities Capabilities { get; } = new(); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _container = new AzuriteBuilder(ContainerImages.Azurite) .WithCommand("--skipApiVersionCheck") @@ -25,7 +25,7 @@ public async Task InitializeAsync() await _container.StartAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container is not null) { diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/FileSystemVirtualFileSystemFixture.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/FileSystemVirtualFileSystemFixture.cs index e14fac7..202d029 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/FileSystemVirtualFileSystemFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/FileSystemVirtualFileSystemFixture.cs @@ -13,20 +13,20 @@ public sealed class FileSystemVirtualFileSystemFixture : IVirtualFileSystemFixtu public VirtualFileSystemCapabilities Capabilities { get; } = new(); - public Task InitializeAsync() + public ValueTask InitializeAsync() { Directory.CreateDirectory(_rootPath); - return Task.CompletedTask; + return ValueTask.CompletedTask; } - public Task DisposeAsync() + public ValueTask DisposeAsync() { if (Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, recursive: true); } - return Task.CompletedTask; + return ValueTask.CompletedTask; } public async Task CreateContextAsync() diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/GcsVirtualFileSystemFixture.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/GcsVirtualFileSystemFixture.cs index c5a59ea..75cc37a 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/GcsVirtualFileSystemFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/GcsVirtualFileSystemFixture.cs @@ -18,15 +18,14 @@ public sealed class GcsVirtualFileSystemFixture : IVirtualFileSystemFixture, IAs public VirtualFileSystemCapabilities Capabilities { get; } = new(Enabled: false); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new FakeGcsServerBuilder(ContainerImages.FakeGCSServer) - .Build(); + _container = GcsContainerFactory.Create(); await _container.StartAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container is not null) { diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/SftpVirtualFileSystemFixture.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/SftpVirtualFileSystemFixture.cs index bd0e72f..6695b9f 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/SftpVirtualFileSystemFixture.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/Fixtures/SftpVirtualFileSystemFixture.cs @@ -21,13 +21,13 @@ public sealed class SftpVirtualFileSystemFixture : IVirtualFileSystemFixture, IA SupportsMove: false, SupportsDirectoryStats: false); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _container = SftpContainerFactory.Create(); await _container.StartAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container is not null) { diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs new file mode 100644 index 0000000..71454ae --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using ManagedCode.Storage.FileSystem; +using ManagedCode.Storage.FileSystem.Options; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Extensions; +using ManagedCode.Storage.VirtualFileSystem.Options; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +internal sealed class LocalVfsSession : IAsyncDisposable +{ + private readonly string _root; + private readonly ServiceProvider _provider; + + private LocalVfsSession(string root, ServiceProvider provider, FileSystemStorage storage) + { + _root = root; + _provider = provider; + Storage = storage; + FileSystem = provider.GetRequiredService(); + } + + public FileSystemStorage Storage { get; } + public string RootDirectory => _root; + public IVirtualFileSystem FileSystem { get; } + + public static async Task CreateAsync(bool cache = true, + DirectoryStrategy strategy = DirectoryStrategy.Virtual, int pageSize = 100) + { + var root = Path.Combine(Path.GetTempPath(), "managedcode-vfs-contracts", Guid.NewGuid().ToString("N")); + var storage = new FileSystemStorage(new FileSystemStorageOptions + { + BaseFolder = root, + CreateContainerIfNotExists = true + }); + (await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddVirtualFileSystem(storage, options => + { + options.DefaultContainer = string.Empty; + options.EnableCache = cache; + options.DirectoryStrategy = strategy; + options.DefaultPageSize = pageSize; + }); + return new LocalVfsSession(root, services.BuildServiceProvider(), storage); + } + + public async ValueTask DisposeAsync() + { + await _provider.DisposeAsync(); + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/MetadataCodecContractsTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/MetadataCodecContractsTests.cs new file mode 100644 index 0000000..8a07944 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/MetadataCodecContractsTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using ManagedCode.Storage.Tests.VirtualFileSystem; +using ManagedCode.Storage.VirtualFileSystem.Extensions; +using ManagedCode.Storage.VirtualFileSystem.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +public sealed class MetadataCodecContractsTests +{ + [Fact] + public async Task ProtectedProviderCodec_RoundTripsFieldsWithoutLeakingReservedKeys() + { + await using var session = await LocalVfsSession.CreateAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddVirtualFileSystem(session.Storage); + await using var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var metadata = new VfsMetadata + { + Version = "1.0", + Created = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(2)), + Modified = new DateTimeOffset(2025, 6, 7, 8, 9, 10, TimeSpan.Zero) + }; + metadata.CustomMetadata["kind"] = "content"; + var custom = new Dictionary { ["category"] = "contract", ["x-vfs-prefixed"] = "value" }; + var encoded = Invoke>(manager, "BuildMetadataDictionary", metadata, custom); + var decoded = Invoke(manager, "ParseVfsMetadata", encoded); + decoded.Version.ShouldBe(metadata.Version); + decoded.Created.ShouldBe(metadata.Created); + decoded.Modified.ShouldBe(metadata.Modified); + decoded.CustomMetadata.Count.ShouldBe(1); + decoded.CustomMetadata["kind"].ShouldBe("content"); + var extra = Invoke>(manager, "ExtractCustomMetadata", encoded); + extra.Count.ShouldBe(2); + extra["category"].ShouldBe("contract"); + extra["prefixed"].ShouldBe("value"); + } + + [Fact] + public async Task MissingOrMalformedOptionalFields_AreHandledWithoutDiscardingUserMetadata() + { + await using var session = await LocalVfsSession.CreateAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddVirtualFileSystem(session.Storage); + await using var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + Invoke(manager, "ParseVfsMetadata", new Dictionary()).ShouldBeNull(); + var encoded = new Dictionary + { + ["x-vfs-vfs-version"] = "1.0", + ["x-vfs-vfs-created"] = "invalid", + ["x-vfs-vfs-modified"] = "invalid", + ["x-vfs-vfs-attributes"] = "invalid", + ["public"] = "outside", + ["x-vfs-extra"] = "inside" + }; + var parsed = Invoke(manager, "ParseVfsMetadata", encoded); + parsed.Version.ShouldBe("1.0"); + parsed.CustomMetadata.ShouldBeEmpty(); + var extra = Invoke>(manager, "ExtractCustomMetadata", encoded); + extra["public"].ShouldBe("outside"); + extra["extra"].ShouldBe("inside"); + } + + // Exercise the protected codec extension points on the real DI-resolved metadata manager. + // No storage or metadata implementation is replaced by a fake. + private static T Invoke(IMetadataManager manager, string name, params object?[] arguments) + => (T)typeof(BaseMetadataManager).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(manager, arguments)!; +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs new file mode 100644 index 0000000..9e9cecf --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs @@ -0,0 +1,38 @@ +using System; +using ManagedCode.Storage.VirtualFileSystem.Options; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +public sealed class SearchPatternContractTests +{ + [Theory] + [InlineData("*.txt", "notes.txt", true)] + [InlineData("*.txt", "NOTES.TXT", true)] + [InlineData("*.txt", "notes.csv", false)] + [InlineData("report-??.txt", "report-12.txt", true)] + [InlineData("report-??.txt", "report-1.txt", false)] + [InlineData("a*b*c", "axxbxxbc", true)] + [InlineData("a*b*c", "axxbxxbd", false)] + [InlineData("a**", "abc", true)] + [InlineData("a?c", "abc", true)] + [InlineData("abc", "abcd", false)] + [InlineData("abc", "abc", true)] + [InlineData("", "abc", false)] + [InlineData("*", "", false)] + [InlineData("*", null, false)] + public void IsMatch_RespectsWildcardAndMissingNameRules(string pattern, string? name, bool expected) + { + new SearchPattern(pattern).IsMatch(name!).ShouldBe(expected); + } + + [Fact] + public void CaseSensitivePattern_PreservesOrdinalCase() + { + var pattern = new SearchPattern("File?.TXT") { CaseSensitive = true }; + pattern.IsMatch("File1.TXT").ShouldBeTrue(); + pattern.IsMatch("file1.txt").ShouldBeFalse(); + Should.Throw(() => new SearchPattern(null!)); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsEntryAndWriteRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsEntryAndWriteRuntimeContractsTests.cs new file mode 100644 index 0000000..099c03a --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsEntryAndWriteRuntimeContractsTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.VirtualFileSystem.Implementations; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Options; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +public sealed class VfsEntryAndWriteRuntimeContractsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task EntryLookupAndSelectiveDeletion_ReflectTheRealStoredTree(bool cache) + { + await using var session = await LocalVfsSession.CreateAsync(cache); + var token = TestContext.Current.CancellationToken; + (await session.Storage.UploadAsync("direct", new UploadOptions { Directory = "tree", FileName = "direct.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await session.Storage.UploadAsync("nested", new UploadOptions { Directory = "tree/child", FileName = "nested.txt" }, token)).IsSuccess.ShouldBeTrue(); + (await session.FileSystem.GetEntryAsync("/tree/direct.txt", token)).ShouldBeOfType(); + (await session.FileSystem.GetEntryAsync("/tree/", token)).ShouldBeOfType(); + (await session.FileSystem.GetEntryAsync("/missing", token)).ShouldBeNull(); + var direct = await session.FileSystem.DeleteDirectoryAsync("/tree", false, token); + direct.Success.ShouldBeTrue(); + direct.FilesDeleted.ShouldBe(1); + (await session.FileSystem.FileExistsAsync("/tree/child/nested.txt", token)).ShouldBeTrue(); + (await session.FileSystem.DirectoryExistsAsync("/tree/child", token)).ShouldBeTrue(); + var nested = await session.FileSystem.GetFileAsync("/tree/child/nested.txt", token); + (await nested.ReadAllBytesAsync(token)).ShouldBe(Encoding.UTF8.GetBytes("nested")); + var all = await session.FileSystem.DeleteDirectoryAsync("/tree", true, token); + all.Success.ShouldBeTrue(); + all.FilesDeleted.ShouldBe(1); + (await session.FileSystem.FileExistsAsync("/tree/child/nested.txt", token)).ShouldBeFalse(); + (await session.FileSystem.DirectoryExistsAsync("/tree/child", token)).ShouldBeFalse(); + var deleted = await session.FileSystem.GetFileAsync("/tree/child/nested.txt", token); + await Should.ThrowAsync(() => deleted.ReadAllBytesAsync(token)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SynchronousWriteDisposal_CommitsSeekedBytesAndReleasesTheBuffer(bool cache) + { + await using var session = await LocalVfsSession.CreateAsync(cache); + var token = TestContext.Current.CancellationToken; + (await session.Storage.UploadAsync("old", new UploadOptions("target.bin"), token)).IsSuccess.ShouldBeTrue(); + var file = await session.FileSystem.GetFileAsync("/target.bin", token); + var stream = await file.OpenWriteAsync(new WriteOptions { Overwrite = true }, token); + await Task.Run(() => + { + stream.CanRead.ShouldBeFalse(); + stream.CanSeek.ShouldBeTrue(); + stream.Write(new byte[] { 1, 2, 3 }, 0, 3); + stream.Seek(1, SeekOrigin.Begin).ShouldBe(1); + stream.Write(new byte[] { 8 }.AsSpan()); + stream.Position = 3; + stream.SetLength(4); + stream.Flush(); + Should.Throw(() => stream.Read(new byte[1], 0, 1)); + stream.Dispose(); + stream.Dispose(); + }, token); + stream.CanWrite.ShouldBeFalse(); + (await file.ReadAllBytesAsync(token)).ShouldBe(new byte[] { 1, 8, 3, 0 }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedWriteDisposal_PropagatesTheActualFilesystemFailure(bool asynchronous) + { + await using var session = await LocalVfsSession.CreateAsync(); + var token = TestContext.Current.CancellationToken; + (await session.Storage.UploadAsync("old", new UploadOptions("target.bin"), token)).IsSuccess.ShouldBeTrue(); + var file = await session.FileSystem.GetFileAsync("/target.bin", token); + var stream = await file.OpenWriteAsync(new WriteOptions { Overwrite = true }, token); + await stream.WriteAsync(Encoding.UTF8.GetBytes("cannot commit"), token); + Directory.Delete(session.RootDirectory, recursive: true); + await File.WriteAllTextAsync(session.RootDirectory, "an actual file blocks the storage directory", token); + try + { + if (asynchronous) await Should.ThrowAsync(() => stream.DisposeAsync().AsTask()); + else await Task.Run(() => Should.Throw(() => stream.Dispose()), token); + stream.CanWrite.ShouldBeFalse(); + } + finally { File.Delete(session.RootDirectory); } + } +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs index 2fa0d71..e1e6ccd 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs @@ -21,8 +21,8 @@ public sealed class VfsImmutableMetadataTests : IAsyncLifetime private readonly AzuriteContainer _container = new AzuriteBuilder(ContainerImages.Azurite) .WithCommand("--skipApiVersionCheck").Build(); - public Task InitializeAsync() => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + public ValueTask InitializeAsync() => new(_container.StartAsync()); + public ValueTask DisposeAsync() => _container.DisposeAsync(); [Theory] [InlineData(false, "material.txt")] @@ -35,9 +35,9 @@ public async Task ImmutableWrite_PreservesMetadataThroughFreshAndPreviouslyEmpty await using var scope = provider.CreateAsyncScope(); var vfs = scope.ServiceProvider.GetRequiredService(); var path = new VfsPath("/learning/material/content"); - var previous = await vfs.GetFileAsync(path); - (await previous.GetMetadataAsync()).ShouldBeEmpty(); - (await vfs.FileExistsAsync(path)).ShouldBeFalse(); + var previous = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + (await previous.GetMetadataAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeFalse(); var bytes = Encoding.UTF8.GetBytes("exact immutable author bytes"); var metadata = new Dictionary { @@ -46,22 +46,22 @@ public async Task ImmutableWrite_PreservesMetadataThroughFreshAndPreviouslyEmpty ["contentType"] = "text/plain" }; var options = new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata }; - var written = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options); + var written = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options, TestContext.Current.CancellationToken); written.ReusedExisting.ShouldBeFalse(); - AssertMetadata(await previous.GetMetadataAsync(), metadata); - (await vfs.FileExistsAsync(path)).ShouldBeTrue(); - var fresh = await vfs.GetFileAsync(path); - await fresh.RefreshAsync(); + AssertMetadata(await previous.GetMetadataAsync(TestContext.Current.CancellationToken), metadata); + (await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeTrue(); + var fresh = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + await fresh.RefreshAsync(TestContext.Current.CancellationToken); fresh.Size.ShouldBe(bytes.LongLength); - AssertMetadata(await fresh.GetMetadataAsync(), metadata); - await using var stream = await fresh.OpenReadAsync(); + AssertMetadata(await fresh.GetMetadataAsync(TestContext.Current.CancellationToken), metadata); + await using var stream = await fresh.OpenReadAsync(cancellationToken: TestContext.Current.CancellationToken); using var content = new MemoryStream(); - await stream.CopyToAsync(content); + await stream.CopyToAsync(content, TestContext.Current.CancellationToken); content.ToArray().ShouldBe(bytes); - var replay = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options); + var replay = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options, TestContext.Current.CancellationToken); replay.ReusedExisting.ShouldBeTrue(); replay.Info.ETag.ShouldBe(written.Info.ETag); - AssertMetadata(await fresh.GetMetadataAsync(), metadata); + AssertMetadata(await fresh.GetMetadataAsync(TestContext.Current.CancellationToken), metadata); } [Fact] @@ -82,12 +82,11 @@ public async Task MetadataRead_PreservesLegacyCustomFieldsAndFiltersOnlyReserved ["x-vfs-vfs-attributes"] = "0", ["x-vfs-vfs-internal"] = "private-vfs-state" }; - await storage.RequireObjectStorage().CreatePrivateContainerAsync(); + await storage.RequireObjectStorage().CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); using var bytes = new MemoryStream(Encoding.UTF8.GetBytes("legacy bytes")); - await storage.RequireObjectStorage().WriteObjectAsync("legacy/content", bytes, - new StorageWriteOptions { Metadata = metadata }); - var file = await vfs.GetFileAsync("/legacy/content"); - AssertMetadata(await file.GetMetadataAsync(), new Dictionary + await storage.RequireObjectStorage().WriteObjectAsync("legacy/content", bytes, new StorageWriteOptions { Metadata = metadata }, TestContext.Current.CancellationToken); + var file = await vfs.GetFileAsync("/legacy/content", TestContext.Current.CancellationToken); + AssertMetadata(await file.GetMetadataAsync(TestContext.Current.CancellationToken), new Dictionary { ["ownerUserId"] = "exact-owner", ["reviewer"] = "legacy-custom", @@ -104,20 +103,20 @@ public async Task Refresh_AfterMultipartCommit_ExposesCommittedBytes(bool cache) await using var scope = provider.CreateAsyncScope(); var vfs = scope.ServiceProvider.GetRequiredService(); var path = new VfsPath("/learning/commands/output.zip"); - var file = await vfs.GetFileAsync(path); - (await file.ExistsAsync()).ShouldBeFalse(); - await file.RefreshAsync(); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + (await file.ExistsAsync(TestContext.Current.CancellationToken)).ShouldBeFalse(); + await file.RefreshAsync(TestContext.Current.CancellationToken); var bytes = Encoding.UTF8.GetBytes("committed archive bytes"); var multipart = vfs.Storage.RequireMultipartStorage(); - await multipart.CreatePrivateContainerAsync(); + await multipart.CreatePrivateContainerAsync(cancellationToken: TestContext.Current.CancellationToken); var partId = Convert.ToBase64String(Encoding.UTF8.GetBytes("archive-part-0001")); using var part = new MemoryStream(bytes); - await multipart.StagePartAsync(path.ToBlobKey(), partId, part); - await multipart.CommitPartsAsync(path.ToBlobKey(), [partId]); - await file.RefreshAsync(); + await multipart.StagePartAsync(path.ToBlobKey(), partId, part, TestContext.Current.CancellationToken); + await multipart.CommitPartsAsync(path.ToBlobKey(), [partId], cancellationToken: TestContext.Current.CancellationToken); + await file.RefreshAsync(TestContext.Current.CancellationToken); file.Size.ShouldBe(bytes.LongLength); - (await file.ExistsAsync()).ShouldBeTrue(); - (await file.ReadAllBytesAsync()).ShouldBe(bytes); + (await file.ExistsAsync(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await file.ReadAllBytesAsync(TestContext.Current.CancellationToken)).ShouldBe(bytes); } [Theory] @@ -129,13 +128,13 @@ public async Task Refresh_AfterProviderDeletion_ReportsMissingFile(bool cache) await using var scope = provider.CreateAsyncScope(); var vfs = scope.ServiceProvider.GetRequiredService(); var path = new VfsPath("/learning/commands/deleted.zip"); - var file = await vfs.GetFileAsync(path); - await file.WriteAllBytesAsync(Encoding.UTF8.GetBytes("previous archive bytes")); - (await file.ExistsAsync()).ShouldBeTrue(); - (await vfs.Storage.RequireObjectStorage().DeleteObjectIfExistsAsync(path.ToBlobKey())).ShouldBeTrue(); - await file.RefreshAsync(); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + await file.WriteAllBytesAsync(Encoding.UTF8.GetBytes("previous archive bytes"), cancellationToken: TestContext.Current.CancellationToken); + (await file.ExistsAsync(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await vfs.Storage.RequireObjectStorage().DeleteObjectIfExistsAsync(path.ToBlobKey(), cancellationToken: TestContext.Current.CancellationToken)).ShouldBeTrue(); + await file.RefreshAsync(TestContext.Current.CancellationToken); file.Size.ShouldBe(0); - (await file.ExistsAsync()).ShouldBeFalse(); + (await file.ExistsAsync(TestContext.Current.CancellationToken)).ShouldBeFalse(); } private ServiceProvider CreateProvider(bool cache) diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualDirectoryRuntimeTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualDirectoryRuntimeTests.cs new file mode 100644 index 0000000..688923b --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualDirectoryRuntimeTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Options; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +public sealed class VirtualDirectoryRuntimeTests +{ + [Theory] + [InlineData(true, 1)] + [InlineData(false, 0)] + public async Task ListingAndStatistics_DescribeActualNestedFiles(bool cache, int pageSize) + { + await using var session = await LocalVfsSession.CreateAsync(cache, pageSize: pageSize); + var vfs = session.FileSystem; + var token = TestContext.Current.CancellationToken; + foreach (var path in new[] { "/docs/first.txt", "/docs/child/second.txt", "/docs/child/deeper/last.bin" }) + { + var file = await vfs.GetFileAsync(new VfsPath(path), token); + await file.WriteAllTextAsync("content", cancellationToken: token); + } + var directory = await vfs.GetDirectoryAsync(new VfsPath("/docs"), token); + directory.Name.ShouldBe("docs"); + directory.Type.ShouldBe(VfsEntryType.Directory); + (await directory.ExistsAsync(token)).ShouldBeTrue(); + (await directory.GetParentAsync(token)).Name.ShouldBe("/"); + var files = new List(); + await foreach (var file in directory.GetFilesAsync(new SearchPattern("*.txt"), true, pageSize, token)) + files.Add(file.Path.Value); + files.Order().ShouldBe(new[] { "/docs/child/second.txt", "/docs/first.txt" }); + var directories = new List(); + await foreach (var child in directory.GetDirectoriesAsync(recursive: true, pageSize: pageSize, cancellationToken: token)) + directories.Add(child.Path.Value); + directories.Order().ShouldBe(new[] { "/docs/child", "/docs/child/deeper" }); + var stats = await directory.GetStatsAsync(cancellationToken: token); + stats.FileCount.ShouldBe(3); + stats.DirectoryCount.ShouldBe(2); + stats.TotalSize.ShouldBe(21); + stats.FilesByExtension[".txt"].ShouldBe(2); + stats.LargestFile.ShouldNotBeNull(); + stats.LargestFile.Size.ShouldBe(7); + stats.OldestModified.ShouldNotBeNull(); + var shallow = await directory.GetStatsAsync(false, token); + shallow.FileCount.ShouldBe(1); + shallow.TotalSize.ShouldBe(7); + } + + [Theory] + [InlineData(DirectoryStrategy.Virtual)] + [InlineData(DirectoryStrategy.DotKeepFile)] + public async Task DirectoryCreation_UsesConfiguredStrategyAndRejectsEmptyNames(DirectoryStrategy strategy) + { + await using var session = await LocalVfsSession.CreateAsync(strategy: strategy); + var token = TestContext.Current.CancellationToken; + var root = await session.FileSystem.GetDirectoryAsync(new VfsPath("/"), token); + var directory = await root.CreateDirectoryAsync("empty", token); + (await directory.ExistsAsync(token)).ShouldBe(strategy == DirectoryStrategy.DotKeepFile); + if (strategy == DirectoryStrategy.DotKeepFile) + { + await directory.RefreshAsync(token); + var marker = await session.FileSystem.GetFileAsync(new VfsPath("/empty/.keep"), token); + (await marker.ReadAllBytesAsync(token)).ShouldBeEmpty(); + } + await Should.ThrowAsync(async () => await root.CreateDirectoryAsync(" ", token)); + await Should.ThrowAsync(async () => await root.CreateFileAsync("", cancellationToken: token)); + var empty = await root.CreateFileAsync("empty.txt", cancellationToken: token); + (await empty.ReadAllBytesAsync(token)).ShouldBeEmpty(); + await Should.ThrowAsync( + async () => await root.CreateFileAsync("empty.txt", cancellationToken: token)); + await root.CreateFileAsync("empty.txt", new CreateFileOptions { Overwrite = true }, token); + (await root.DeleteAsync(true, token)).ShouldNotBeNull(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileRuntimeTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileRuntimeTests.cs new file mode 100644 index 0000000..fd8a374 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileRuntimeTests.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Options; +using Shouldly; +using Xunit; + +namespace ManagedCode.Storage.Tests.VirtualFileSystem; + +public sealed class VirtualFileRuntimeTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteStream_PersistsSyncAndAsyncWritesAndEnforcesLifetime(bool cache) + { + await using var session = await LocalVfsSession.CreateAsync(cache); + var token = TestContext.Current.CancellationToken; + var file = await session.FileSystem.GetFileAsync(new VfsPath("/contract.bin"), token); + var stream = await file.OpenWriteAsync(cancellationToken: token); + stream.CanRead.ShouldBeFalse(); + stream.CanSeek.ShouldBeTrue(); + stream.CanWrite.ShouldBeTrue(); + stream.Write(new byte[] { 1, 2 }, 0, 2); + stream.Write(new byte[] { 3 }.AsSpan()); + await stream.WriteAsync(new byte[] { 4 }, 0, 1, token); + await stream.WriteAsync(new byte[] { 5 }.AsMemory(), token); + stream.Position.ShouldBe(5); + stream.Length.ShouldBe(5); + stream.Seek(0, SeekOrigin.Begin).ShouldBe(0); + stream.SetLength(4); + stream.Flush(); + await stream.FlushAsync(token); + Should.Throw(() => stream.Read(new byte[1], 0, 1)); + await stream.DisposeAsync(); + await stream.DisposeAsync(); + stream.CanWrite.ShouldBeFalse(); + Should.Throw(() => stream.Write(new byte[1], 0, 1)); + (await file.ReadAllBytesAsync(token)).ShouldBe(new byte[] { 1, 2, 3, 4 }); + await file.RefreshAsync(token); + file.Size.ShouldBe(4); + file.Type.ShouldBe(VfsEntryType.File); + (await file.GetParentAsync(token)).Name.ShouldBe("/"); + var metadata = await file.GetMetadataAsync(token); + (await file.GetMetadataAsync(token)).ShouldBe(metadata); + await Should.ThrowAsync(() => file.OpenWriteAsync(new WriteOptions { Overwrite = false }, token)); + await Should.ThrowAsync(() => file.OpenWriteAsync(new WriteOptions { ExpectedETag = "stale" }, token)); + await Should.ThrowAsync(() => file.SetMetadataAsync(new System.Collections.Generic.Dictionary(), "stale", token)); + await Should.ThrowAsync(() => file.StartMultipartUploadAsync(token)); + (await file.DeleteAsync(token)).ShouldBeTrue(); + } + + [Theory] + [InlineData(0, 3, "012")] + [InlineData(3, 4, "3456")] + [InlineData(8, 4, "89")] + [InlineData(20, 4, "")] + public async Task RangeRead_ReturnsBytesFromRequestedOffset(long offset, int count, string expected) + { + await using var session = await LocalVfsSession.CreateAsync(); + var token = TestContext.Current.CancellationToken; + var file = await session.FileSystem.GetFileAsync(new VfsPath("/range.txt"), token); + await file.WriteAllTextAsync("0123456789", cancellationToken: token); + Encoding.UTF8.GetString(await file.ReadRangeAsync(offset, count, token)).ShouldBe(expected); + } + + [Fact] + public async Task MissingAndFailedWrites_ExposeErrorsWithoutCreatingFiles() + { + await using var session = await LocalVfsSession.CreateAsync(); + var token = TestContext.Current.CancellationToken; + var missing = await session.FileSystem.GetFileAsync(new VfsPath("/missing"), token); + await Should.ThrowAsync(() => missing.OpenReadAsync(cancellationToken: token)); + await Should.ThrowAsync(() => missing.ReadAllBytesAsync(token)); + var parentFile = await session.FileSystem.GetFileAsync(new VfsPath("/obstruction"), token); + await parentFile.WriteAllTextAsync("parent is a file", cancellationToken: token); + var blocked = await session.FileSystem.GetFileAsync(new VfsPath("/obstruction/child"), token); + await Should.ThrowAsync(() => blocked.WriteAllTextAsync("cannot persist", cancellationToken: token)); + (await blocked.ExistsAsync(token)).ShouldBeFalse(); + } +} diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemManagerTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemManagerTests.cs index 8fd88f7..80de018 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemManagerTests.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemManagerTests.cs @@ -19,7 +19,7 @@ public class VirtualFileSystemManagerTests : IAsyncLifetime private ServiceProvider _serviceProvider = null!; private IStorage _storage = null!; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { Directory.CreateDirectory(_basePath); var services = new ServiceCollection(); @@ -42,7 +42,7 @@ public async Task InitializeAsync() await _storage.CreateContainerAsync(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_serviceProvider.GetService() is IAsyncDisposable asyncManager) { @@ -56,17 +56,18 @@ public async Task DisposeAsync() { Directory.Delete(_basePath, recursive: true); } + GC.SuppressFinalize(this); } [Fact] public async Task MountAndResolvePaths_ShouldWork() { var manager = _serviceProvider.GetRequiredService(); - await manager.MountAsync("/fs", _storage, new VfsOptions { DefaultContainer = string.Empty }); + await manager.MountAsync("/fs", _storage, new VfsOptions { DefaultContainer = string.Empty }, TestContext.Current.CancellationToken); var vfs = manager.GetMount("/fs"); - var file = await vfs.GetFileAsync(new VfsPath("/sample.txt")); - await file.WriteAllTextAsync("manager-test"); + var file = await vfs.GetFileAsync(new VfsPath("/sample.txt"), TestContext.Current.CancellationToken); + await file.WriteAllTextAsync("manager-test", cancellationToken: TestContext.Current.CancellationToken); var (mountPoint, relativePath) = manager.ResolvePath("/fs/sample.txt"); mountPoint.ShouldBe("/fs"); @@ -75,7 +76,7 @@ public async Task MountAndResolvePaths_ShouldWork() var mounts = manager.GetMounts(); mounts.ShouldContainKey("/fs"); - await manager.UnmountAsync("/fs"); + await manager.UnmountAsync("/fs", TestContext.Current.CancellationToken); mounts = manager.GetMounts(); mounts.ShouldBeEmpty(); diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemTests.cs index 3dae5cd..a48e171 100644 --- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemTests.cs +++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VirtualFileSystemTests.cs @@ -35,13 +35,13 @@ public async Task WriteAndReadFile_ShouldRoundtrip() await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - var file = await vfs.GetFileAsync(new VfsPath("/docs/readme.txt")); - await file.WriteAllTextAsync("Hello Virtual FS!"); + var file = await vfs.GetFileAsync(new VfsPath("/docs/readme.txt"), TestContext.Current.CancellationToken); + await file.WriteAllTextAsync("Hello Virtual FS!", cancellationToken: TestContext.Current.CancellationToken); - var content = await file.ReadAllTextAsync(); + var content = await file.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken); content.ShouldBe("Hello Virtual FS!"); - (await vfs.FileExistsAsync(new VfsPath("/docs/readme.txt"))).ShouldBeTrue(); + (await vfs.FileExistsAsync(new VfsPath("/docs/readme.txt"), TestContext.Current.CancellationToken)).ShouldBeTrue(); } [Fact] @@ -52,16 +52,16 @@ public async Task FileExistsAsync_ShouldCacheResults() var metadataManager = context.MetadataManager; var path = new VfsPath("/cache/sample.txt"); - var file = await vfs.GetFileAsync(path); - await file.WriteAllTextAsync("cached"); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + await file.WriteAllTextAsync("cached", cancellationToken: TestContext.Current.CancellationToken); metadataManager.ResetCounters(); - var firstCheck = await vfs.FileExistsAsync(path); + var firstCheck = await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken); firstCheck.ShouldBeTrue(); metadataManager.BlobInfoRequests.ShouldBe(0); metadataManager.ResetCounters(); - var secondCheck = await vfs.FileExistsAsync(path); + var secondCheck = await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken); secondCheck.ShouldBeTrue(); metadataManager.BlobInfoRequests.ShouldBe(0); } @@ -71,16 +71,16 @@ public async Task StorageFileExistsAsync_BypassesCachedAbsence() { await using var context = await CreateContextAsync(); var path = new VfsPath($"/fresh/{Guid.NewGuid():N}.txt"); - (await context.FileSystem.FileExistsAsync(path)).ShouldBeFalse(); + (await context.FileSystem.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeFalse(); var upload = await context.Storage.UploadAsync("created externally", new UploadOptions { FileName = path.ToBlobKey() - }); + }, TestContext.Current.CancellationToken); upload.ThrowIfProblem(); - (await context.FileSystem.StorageFileExistsAsync(path)).ShouldBeTrue(); - (await context.FileSystem.FileExistsAsync(path)).ShouldBeFalse(); + (await context.FileSystem.StorageFileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await context.FileSystem.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] @@ -97,16 +97,16 @@ public async Task ListAsync_ShouldEnumerateAllEntries() for (var i = 0; i < 5; i++) { - var file = await vfs.GetFileAsync(new VfsPath($"/reports/file-{i}.txt")); - await file.WriteAllTextAsync($"report-{i}"); + var file = await vfs.GetFileAsync(new VfsPath($"/reports/file-{i}.txt"), TestContext.Current.CancellationToken); + await file.WriteAllTextAsync($"report-{i}", cancellationToken: TestContext.Current.CancellationToken); } - var sampleMetadata = await metadataManager.GetBlobInfoAsync("reports/file-0.txt"); + var sampleMetadata = await metadataManager.GetBlobInfoAsync("reports/file-0.txt", TestContext.Current.CancellationToken); sampleMetadata.ShouldNotBeNull(); sampleMetadata!.FullName.ShouldBe("reports/file-0.txt"); var entries = new List(); - await foreach (var entry in vfs.ListAsync(new VfsPath("/reports"), new ListOptions { PageSize = 2 })) + await foreach (var entry in vfs.ListAsync(new VfsPath("/reports"), new ListOptions { PageSize = 2 }, TestContext.Current.CancellationToken)) { entries.Add(entry); } @@ -128,22 +128,22 @@ public async Task DeleteFile_ShouldRemoveFromUnderlyingStorage() var metadataManager = context.MetadataManager; var path = new VfsPath("/temp/remove.me"); - var file = await vfs.GetFileAsync(path); - await file.WriteAllTextAsync("to delete"); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); + await file.WriteAllTextAsync("to delete", cancellationToken: TestContext.Current.CancellationToken); metadataManager.ResetCounters(); - await vfs.FileExistsAsync(path); + await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken); metadataManager.ResetCounters(); - var deleted = await file.DeleteAsync(); + var deleted = await file.DeleteAsync(TestContext.Current.CancellationToken); deleted.ShouldBeTrue(); - var existsAfterDelete = await vfs.FileExistsAsync(path); + var existsAfterDelete = await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken); existsAfterDelete.ShouldBeFalse(); metadataManager.BlobInfoRequests.ShouldBe(0); metadataManager.ResetCounters(); - var secondCheck = await vfs.FileExistsAsync(path); + var secondCheck = await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken); secondCheck.ShouldBeFalse(); metadataManager.BlobInfoRequests.ShouldBe(0); } @@ -155,7 +155,7 @@ public async Task FileExistsAsync_WhenFileIsMissing_ShouldNotReadMetadata() var path = new VfsPath($"/missing/{Guid.NewGuid():N}.txt"); context.MetadataManager.ResetCounters(); - var exists = await context.FileSystem.FileExistsAsync(path); + var exists = await context.FileSystem.FileExistsAsync(path, TestContext.Current.CancellationToken); exists.ShouldBeFalse(); context.MetadataManager.BlobInfoRequests.ShouldBe(0); @@ -168,22 +168,22 @@ public async Task GetMetadataAsync_ShouldCacheCustomMetadata() var vfs = context.FileSystem; var metadataManager = context.MetadataManager; - var file = await vfs.GetFileAsync(new VfsPath("/meta/info.txt")); - await file.WriteAllTextAsync("meta"); + var file = await vfs.GetFileAsync(new VfsPath("/meta/info.txt"), TestContext.Current.CancellationToken); + await file.WriteAllTextAsync("meta", cancellationToken: TestContext.Current.CancellationToken); await file.SetMetadataAsync(new Dictionary { ["owner"] = "qa", ["region"] = "eu" - }); + }, cancellationToken: TestContext.Current.CancellationToken); metadataManager.ResetCounters(); - var metadata = await file.GetMetadataAsync(); + var metadata = await file.GetMetadataAsync(TestContext.Current.CancellationToken); metadata.ShouldContainKey("owner"); metadataManager.CustomMetadataRequests.ShouldBe(1); metadataManager.ResetCounters(); - var secondLookup = await file.GetMetadataAsync(); + var secondLookup = await file.GetMetadataAsync(TestContext.Current.CancellationToken); secondLookup.ShouldContainKey("region"); metadataManager.CustomMetadataRequests.ShouldBe(0); } @@ -199,18 +199,18 @@ public async Task WriteAndReadFile_WithUnicodeDirectories_ShouldRoundtrip( var vfs = context.FileSystem; var path = new VfsPath($"/international/{directoryName}/{fileName}.txt"); - var file = await vfs.GetFileAsync(path); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); - await file.WriteAllTextAsync(content); + await file.WriteAllTextAsync(content, cancellationToken: TestContext.Current.CancellationToken); - (await file.ReadAllTextAsync()).ShouldBe(content); + (await file.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(content); file.Path.GetFileName().ShouldBe($"{fileName}.txt"); file.Path.GetFileNameWithoutExtension().ShouldBe(fileName); file.Path.GetExtension().ShouldBe(".txt"); file.Path.GetParent().Value.ShouldBe($"/international/{directoryName}"); file.Path.ToBlobKey().ShouldBe($"international/{directoryName}/{fileName}.txt"); - (await vfs.FileExistsAsync(path)).ShouldBeTrue(); + (await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeTrue(); if (Capabilities.SupportsListing) { @@ -220,7 +220,7 @@ public async Task WriteAndReadFile_WithUnicodeDirectories_ShouldRoundtrip( IncludeFiles = true, IncludeDirectories = false, Recursive = false - })) + }, TestContext.Current.CancellationToken)) { entries.Add(entry); } @@ -228,8 +228,8 @@ public async Task WriteAndReadFile_WithUnicodeDirectories_ShouldRoundtrip( entries.ShouldContain(e => e.Path.Value == path.Value); } - (await file.DeleteAsync()).ShouldBeTrue(); - (await vfs.FileExistsAsync(path)).ShouldBeFalse(); + (await file.DeleteAsync(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await vfs.FileExistsAsync(path, TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] @@ -243,14 +243,14 @@ public async Task DeleteDirectoryAsync_NonRecursive_ShouldPreserveNestedContent( await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - await (await vfs.GetFileAsync(new VfsPath("/nonrec/root.txt"))).WriteAllTextAsync("root"); - await (await vfs.GetFileAsync(new VfsPath("/nonrec/sub/nested.txt"))).WriteAllTextAsync("child"); + await (await vfs.GetFileAsync(new VfsPath("/nonrec/root.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("root", cancellationToken: TestContext.Current.CancellationToken); + await (await vfs.GetFileAsync(new VfsPath("/nonrec/sub/nested.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("child", cancellationToken: TestContext.Current.CancellationToken); - var result = await vfs.DeleteDirectoryAsync(new VfsPath("/nonrec"), recursive: false); + var result = await vfs.DeleteDirectoryAsync(new VfsPath("/nonrec"), recursive: false, cancellationToken: TestContext.Current.CancellationToken); result.FilesDeleted.ShouldBe(1); - (await vfs.FileExistsAsync(new VfsPath("/nonrec/root.txt"))).ShouldBeFalse(); - (await vfs.FileExistsAsync(new VfsPath("/nonrec/sub/nested.txt"))).ShouldBeTrue(); + (await vfs.FileExistsAsync(new VfsPath("/nonrec/root.txt"), TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await vfs.FileExistsAsync(new VfsPath("/nonrec/sub/nested.txt"), TestContext.Current.CancellationToken)).ShouldBeTrue(); } [Fact] @@ -264,14 +264,14 @@ public async Task DeleteDirectoryAsync_Recursive_ShouldRemoveAllContent() await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - await (await vfs.GetFileAsync(new VfsPath("/recursive/root.txt"))).WriteAllTextAsync("root"); - await (await vfs.GetFileAsync(new VfsPath("/recursive/sub/nested.txt"))).WriteAllTextAsync("child"); + await (await vfs.GetFileAsync(new VfsPath("/recursive/root.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("root", cancellationToken: TestContext.Current.CancellationToken); + await (await vfs.GetFileAsync(new VfsPath("/recursive/sub/nested.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("child", cancellationToken: TestContext.Current.CancellationToken); - var result = await vfs.DeleteDirectoryAsync(new VfsPath("/recursive"), recursive: true); + var result = await vfs.DeleteDirectoryAsync(new VfsPath("/recursive"), recursive: true, cancellationToken: TestContext.Current.CancellationToken); result.FilesDeleted.ShouldBe(2); - (await vfs.FileExistsAsync(new VfsPath("/recursive/root.txt"))).ShouldBeFalse(); - (await vfs.FileExistsAsync(new VfsPath("/recursive/sub/nested.txt"))).ShouldBeFalse(); + (await vfs.FileExistsAsync(new VfsPath("/recursive/root.txt"), TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await vfs.FileExistsAsync(new VfsPath("/recursive/sub/nested.txt"), TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] @@ -287,16 +287,16 @@ public async Task MoveAsync_ShouldRelocateFile() var sourcePath = new VfsPath("/docs/report.pdf"); var destPath = new VfsPath("/archive/report.pdf"); - var file = await vfs.GetFileAsync(sourcePath); - await file.WriteAllBytesAsync(new byte[] { 1, 2, 3, 4 }); + var file = await vfs.GetFileAsync(sourcePath, TestContext.Current.CancellationToken); + await file.WriteAllBytesAsync(new byte[] { 1, 2, 3, 4 }, cancellationToken: TestContext.Current.CancellationToken); - await vfs.MoveAsync(sourcePath, destPath); + await vfs.MoveAsync(sourcePath, destPath, cancellationToken: TestContext.Current.CancellationToken); - var moved = await vfs.GetFileAsync(destPath); - var bytes = await moved.ReadAllBytesAsync(); + var moved = await vfs.GetFileAsync(destPath, TestContext.Current.CancellationToken); + var bytes = await moved.ReadAllBytesAsync(TestContext.Current.CancellationToken); bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); - var original = await vfs.GetFileAsync(sourcePath); + var original = await vfs.GetFileAsync(sourcePath, TestContext.Current.CancellationToken); await Should.ThrowAsync(() => original.ReadAllBytesAsync()); } @@ -313,24 +313,24 @@ public async Task CopyAsync_ShouldCopyDirectoryRecursively() for (var i = 0; i < 3; i++) { - var file = await vfs.GetFileAsync(new VfsPath($"/src/data-{i}.bin")); - await file.WriteAllBytesAsync(new byte[] { (byte)i }); + var file = await vfs.GetFileAsync(new VfsPath($"/src/data-{i}.bin"), TestContext.Current.CancellationToken); + await file.WriteAllBytesAsync(new byte[] { (byte)i }, cancellationToken: TestContext.Current.CancellationToken); } - var nested = await vfs.GetFileAsync(new VfsPath("/src/nested/item.txt")); - await nested.WriteAllTextAsync("nested"); + var nested = await vfs.GetFileAsync(new VfsPath("/src/nested/item.txt"), TestContext.Current.CancellationToken); + await nested.WriteAllTextAsync("nested", cancellationToken: TestContext.Current.CancellationToken); - await vfs.CopyAsync(new VfsPath("/src"), new VfsPath("/dest"), new CopyOptions { Recursive = true, Overwrite = true }); + await vfs.CopyAsync(new VfsPath("/src"), new VfsPath("/dest"), new CopyOptions { Recursive = true, Overwrite = true }, cancellationToken: TestContext.Current.CancellationToken); for (var i = 0; i < 3; i++) { - var copied = await vfs.GetFileAsync(new VfsPath($"/dest/data-{i}.bin")); - var bytes = await copied.ReadAllBytesAsync(); + var copied = await vfs.GetFileAsync(new VfsPath($"/dest/data-{i}.bin"), TestContext.Current.CancellationToken); + var bytes = await copied.ReadAllBytesAsync(TestContext.Current.CancellationToken); bytes.ShouldBe(new byte[] { (byte)i }); } - var copiedNested = await vfs.GetFileAsync(new VfsPath("/dest/nested/item.txt")); - (await copiedNested.ReadAllTextAsync()).ShouldBe("nested"); + var copiedNested = await vfs.GetFileAsync(new VfsPath("/dest/nested/item.txt"), TestContext.Current.CancellationToken); + (await copiedNested.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken)).ShouldBe("nested"); } [Fact] @@ -339,10 +339,10 @@ public async Task ReadRangeAsync_ShouldReturnSlice() await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - var file = await vfs.GetFileAsync(new VfsPath("/ranges/sample.bin")); - await file.WriteAllBytesAsync(Enumerable.Range(0, 100).Select(i => (byte)i).ToArray()); + var file = await vfs.GetFileAsync(new VfsPath("/ranges/sample.bin"), TestContext.Current.CancellationToken); + await file.WriteAllBytesAsync(Enumerable.Range(0, 100).Select(i => (byte)i).ToArray(), cancellationToken: TestContext.Current.CancellationToken); - var slice = await file.ReadRangeAsync(0, 5); + var slice = await file.ReadRangeAsync(0, 5, TestContext.Current.CancellationToken); slice.ShouldBe(new byte[] { 0, 1, 2, 3, 4 }); } @@ -357,8 +357,8 @@ public async Task ListAsync_WithDirectoryFilter_ShouldExcludeDirectoriesWhenRequ await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - await (await vfs.GetFileAsync(new VfsPath("/filter/a.txt"))).WriteAllTextAsync("A"); - await (await vfs.GetFileAsync(new VfsPath("/filter/b.log"))).WriteAllTextAsync("B"); + await (await vfs.GetFileAsync(new VfsPath("/filter/a.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("A", cancellationToken: TestContext.Current.CancellationToken); + await (await vfs.GetFileAsync(new VfsPath("/filter/b.log"), TestContext.Current.CancellationToken)).WriteAllTextAsync("B", cancellationToken: TestContext.Current.CancellationToken); var entries = new List(); await foreach (var entry in vfs.ListAsync(new VfsPath("/filter"), new ListOptions @@ -366,7 +366,7 @@ public async Task ListAsync_WithDirectoryFilter_ShouldExcludeDirectoriesWhenRequ IncludeDirectories = false, IncludeFiles = true, Recursive = false - })) + }, TestContext.Current.CancellationToken)) { entries.Add(entry); } @@ -389,11 +389,11 @@ public async Task DirectoryStats_ShouldAggregateInformation() await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - await (await vfs.GetFileAsync(new VfsPath("/stats/one.txt"))).WriteAllTextAsync("one"); - await (await vfs.GetFileAsync(new VfsPath("/stats/two.bin"))).WriteAllBytesAsync(new byte[] { 1, 2, 3, 4 }); + await (await vfs.GetFileAsync(new VfsPath("/stats/one.txt"), TestContext.Current.CancellationToken)).WriteAllTextAsync("one", cancellationToken: TestContext.Current.CancellationToken); + await (await vfs.GetFileAsync(new VfsPath("/stats/two.bin"), TestContext.Current.CancellationToken)).WriteAllBytesAsync(new byte[] { 1, 2, 3, 4 }, cancellationToken: TestContext.Current.CancellationToken); - var directory = await vfs.GetDirectoryAsync(new VfsPath("/stats")); - var stats = await directory.GetStatsAsync(); + var directory = await vfs.GetDirectoryAsync(new VfsPath("/stats"), TestContext.Current.CancellationToken); + var stats = await directory.GetStatsAsync(cancellationToken: TestContext.Current.CancellationToken); stats.FileCount.ShouldBeGreaterThanOrEqualTo(2); stats.FilesByExtension.ShouldContainKey(".txt"); @@ -412,11 +412,11 @@ public async Task LargeFile_ShouldRoundTripViaStreams(int gigabytes) await using var context = await CreateContextAsync(); var vfs = context.FileSystem; - await using var sourceFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin"); + await using var sourceFile = await LargeFileTestHelper.CreateRandomFileAsync(sizeBytes, ".bin", cancellationToken: TestContext.Current.CancellationToken); var expectedCrc = LargeFileTestHelper.CalculateFileCrc(sourceFile.FilePath); var path = new VfsPath($"/large/{Guid.NewGuid():N}.bin"); - var file = await vfs.GetFileAsync(path); + var file = await vfs.GetFileAsync(path, TestContext.Current.CancellationToken); await using (var writeStream = await file.OpenWriteAsync(cancellationToken: CancellationToken.None)) await using (var readSource = File.OpenRead(sourceFile.FilePath)) @@ -430,7 +430,7 @@ public async Task LargeFile_ShouldRoundTripViaStreams(int gigabytes) actualCrc.ShouldBe(expectedCrc); } - (await file.DeleteAsync()).ShouldBeTrue(); + (await file.DeleteAsync(TestContext.Current.CancellationToken)).ShouldBeTrue(); } } diff --git a/Tests/ManagedCode.Storage.Tests/xunit.runner.json b/Tests/ManagedCode.Storage.Tests/xunit.runner.json new file mode 100644 index 0000000..6366089 --- /dev/null +++ b/Tests/ManagedCode.Storage.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "maxParallelThreads": 4 +} diff --git a/docs/Development/setup.md b/docs/Development/setup.md index 95c412c..0958c76 100644 --- a/docs/Development/setup.md +++ b/docs/Development/setup.md @@ -47,6 +47,18 @@ The full test strategy (suite layout, categories, containers, cloud-drive HTTP f - [Testing Strategy](../Testing/strategy.md) +## Coverage + +Local verification, PR validation and release use the same Coverlet MSBuild driver: + +```bash +dotnet test Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj --configuration Release --filter "Category!=BrowserStress" /p:CollectCoverage=true +``` + +The test project writes `artifacts/coverage/coverage.json`, `coverage.cobertura.xml` and `coverage.opencover.xml`. The run fails below 85% total line coverage or 70% total branch coverage across the published packages. Only the two executable browser test hosts are outside this product metric; no production provider, including Browser or Data Lake, is excluded. CI preserves these artifacts and the test results, and uploads the exact Cobertura file to Codecov. + +Browser end-to-end and stress verification remain required in both Interactive Server and WebAssembly. Managed browser-provider coverage is collected through the real Interactive Server process using the instrumented test output, with graceful shutdown before the report is finalized. WebAssembly outcomes are runtime verification and are not presented as separately measured managed-WASM coverage. + ## Formatting ```bash @@ -61,3 +73,23 @@ dotnet format ManagedCode.Storage.slnx - GitHub Actions now use tiered browser large-file coverage: `build-and-test` keeps a fast `128 MiB` browser large-file lane in the default suite, while a separate `browser-stress` lane runs the heavier `256 MiB` browser stress checks automatically for CI and release gating. - Never commit secrets (cloud keys, OAuth tokens, connection strings). Use environment variables or user secrets. - Credentials for cloud-drive providers are documented in `docs/Development/credentials.md`. + +### Regression scope for the coverage repair + +The provider suites exercise SDKs against LocalStack, Azurite, FakeGcsServer and SFTP, including unavailable services and default/keyed registrations. GCS streaming tests use the emulator's filesystem backend. ADLS Gen2 uses the documented external protocol test exception in the test project's `AGENTS.md`; it does not qualify hosted Azure authentication. + +The HTTP tests run the library's `StorageControllerBase` against Kestrel and the filesystem provider. They verify multipart field binding, chunk rejection, checksums, persisted bytes and HTTP ranges. The HTTP client preserves empty successful chunk acknowledgements and checks JSON `Result` failures before completing an upload. + +Regression tests also cover offset and EOF handling in VFS reads, directory statistics, deletion-cache invalidation, failed synchronous/asynchronous write commits, canceled container creation, native SFTP upload cancellation, password/private-key SFTP round trips, and existing files without extensions. Actual InputFile browser uploads verify byte integrity through both memory/disk branches and controller/storage helpers, configured limits above 500 KB, and disposal of the upload-owned temporary files. Azure SDK stream creation and option callbacks retain container identity and credential references; Google Drive default options retain the shared-drive flag. `LocalFile` preserves the path of an existing extensionless file; generated extensionless paths retain their temporary-file naming policy. + +The release workflow validates every published `.nupkg` against its packed identity, version and repository commit on the public NuGet feed before creating the GitHub release. A successful push command alone is not a publication receipt. + +### Container versions and service qualification + +Tests pin Azurite 3.37.0 and fake-gcs-server 1.56.1. The SFTP Alpine image is pinned to the multi-platform digest published on 2026-10-07. Floating `latest` tags can reuse older local images; explicit versions/digests keep local and CI infrastructure reproducible. LocalStack remains on the last pre-auth release 4.14.0: current 2026.09.0 requires a LocalStack auth token, which is not configured locally or in repository CI secrets. + +fake-gcs-server 1.56.1 does not model temporary holds. The real SDK regression proves that an unsupported backend cannot be acknowledged as a successful hold: the returned object must confirm the requested state. Positive hosted GCS hold/authentication qualification remains separate. Azurite does not provide the Data Lake DFS hierarchical-namespace service. SFTP tests use a logical storage directory inside the writable upload mount; removal must delete that directory while preserving the protected chroot mount. Separate tests require a failure when attempting to remove entries protected by chroot-parent permissions. + +The Orleans clear-state regression uses the actual running silo serializer and filesystem provider. `RecordExists=false` must survive serializers that omit CLR default values. The record property retains its existing public default of `true`; serialization attributes express that default and force System.Text.Json to preserve the flag. Field names/types and legacy positive records without the flag remain readable. + +GCS fixture startup writes the launch script to a staging file, waits for copying to finish, and atomically renames it before the container shell reads it. This prevents the captured Testcontainers 4.16.0 extraction/execution race (`Text file busy`), while retaining the real emulator and dynamically mapped SDK upload endpoint. Concurrent startup regressions verify byte round trips with both memory and filesystem backends. The test project copies `xunit.runner.json` to its output and limits collection parallelism to four threads, keeping container creation bounded independently of the host CPU count.