diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs index 80ba8cec5..d3b6e16b8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs @@ -321,11 +321,11 @@ public async Task GetValidatedContentAsync_WithNonMatchingContentId_ReturnsFailu } /// - /// Verifies that completes preparation. + /// Verifies that delivers CSV catalog files before validation. /// /// A task representing the asynchronous test operation. [Fact] - public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync() + public async Task PrepareContentAsync_WithValidManifest_DeliversContentAsync() { var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); var manifest = new ContentManifest @@ -335,19 +335,132 @@ public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync() Version = "1.08", ContentType = ContentType.GameInstallation, TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + var deliveredManifest = new ContentManifest + { + Id = manifest.Id, + Name = manifest.Name, + Version = "1.08-delivered", + ContentType = manifest.ContentType, + TargetGame = manifest.TargetGame, + Files = manifest.Files, }; + var workingDirectory = "C:\\test\\dir"; + var progress = Mock.Of>(); + using var cancellationSource = new CancellationTokenSource(); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(true); + mockDeliverer + .Setup(d => d.DeliverContentAsync(manifest, workingDirectory, progress, cancellationSource.Token)) + .ReturnsAsync(OperationResult.CreateSuccess(deliveredManifest)); var mockValidator = new Mock(); mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ValidationResult(manifestId, [])); - mockValidator.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + mockValidator.Setup(v => v.ValidateAllAsync(workingDirectory, deliveredManifest, It.IsAny>(), cancellationSource.Token)) .ReturnsAsync(new ValidationResult(manifestId, [])); - var provider = CreateProvider(validator: mockValidator.Object); + var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object); - var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir"); + var result = await provider.PrepareContentAsync(manifest, workingDirectory, progress, cancellationSource.Token); result.Success.Should().BeTrue(); + result.Data.Should().BeSameAs(deliveredManifest); + mockDeliverer.Verify(d => d.CanDeliver(manifest), Times.Once); + mockDeliverer.Verify( + d => d.DeliverContentAsync(manifest, workingDirectory, progress, cancellationSource.Token), + Times.Once); + mockValidator.Verify( + v => v.ValidateAllAsync(workingDirectory, deliveredManifest, It.IsAny>(), cancellationSource.Token), + Times.Once); + } + + /// + /// Verifies that preparation fails without attempting delivery when the HTTP deliverer cannot handle the manifest. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task PrepareContentAsync_WhenDelivererCannotDeliver_ReturnsFailureAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(false); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object); + + var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir"); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Contain("Cannot deliver content"); + mockDeliverer.Verify( + d => d.DeliverContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that a delivery failure is returned without continuing to final validation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task PrepareContentAsync_WhenDeliveryFails_ReturnsFailureAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(true); + mockDeliverer + .Setup(d => d.DeliverContentAsync(manifest, It.IsAny(), null, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Download failed")); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object); + + var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir"); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Be("Content delivery failed: Download failed"); + mockValidator.Verify( + v => v.ValidateAllAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); } private static IInstallationInstructionsService CreateMockInstallationService() @@ -366,7 +479,9 @@ private static IInstallationInstructionsService CreateMockInstallationService() return mockInstallService.Object; } - private static CsvContentProvider CreateProvider(IContentValidator? validator = null) + private static CsvContentProvider CreateProvider( + IContentValidator? validator = null, + IContentDeliverer? deliverer = null) { var mockDiscoverer = new Mock(); mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); @@ -380,7 +495,7 @@ private static CsvContentProvider CreateProvider(IContentValidator? validator = return new CsvContentProvider( [mockDiscoverer.Object], [mockResolver.Object], - [mockDeliverer.Object], + [deliverer ?? mockDeliverer.Object], Mock.Of>(), validator ?? Mock.Of(), CreateMockInstallationService()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs new file mode 100644 index 000000000..55e262b92 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs @@ -0,0 +1,235 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.ContentDeliverers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.ContentDeliverers; + +/// +/// Unit tests for . +/// +public class HttpContentDelivererTests +{ + /// + /// Verifies that delivery preserves the authoritative manifest and file metadata. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeliverContentAsync_WithRemoteFile_PreservesManifestAndFileMetadataAsync() + { + var targetDirectory = CreateTargetDirectory(); + const string relativePath = "data/game.dat"; + const string expectedHash = "0123456789abcdef"; + var manifest = CreateManifest("generals-1.08-en", "1.08", relativePath, expectedHash); + var downloadService = CreateSuccessfulDownloadService(); + var deliverer = CreateDeliverer(downloadService.Object); + var expectedDestinationPath = Path.GetFullPath(relativePath, Path.GetFullPath(targetDirectory)); + + try + { + var result = await deliverer.DeliverContentAsync(manifest, targetDirectory); + + result.Success.Should().BeTrue(); + result.Data.Should().BeSameAs(manifest); + result.Data!.Id.Should().Be(manifest.Id); + result.Data.Version.Should().Be("1.08"); + result.Data.Files.Should().ContainSingle(); + result.Data.Files[0].SourceType.Should().Be(ContentSourceType.RemoteDownload); + result.Data.Files[0].Hash.Should().Be(expectedHash); + result.Data.Files[0].Size.Should().Be(7); + result.Data.Files[0].IsRequired.Should().BeFalse(); + result.Data.Files[0].InstallTarget.Should().Be(ContentInstallTarget.Workspace); + File.Exists(expectedDestinationPath).Should().BeTrue(); + + downloadService.Verify( + d => d.DownloadFileAsync( + new Uri("https://example.com/game.dat"), + expectedDestinationPath, + expectedHash, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Verifies that repeated delivery calls return only their own manifest state. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeliverContentAsync_CalledRepeatedly_DoesNotShareManifestStateAsync() + { + var targetDirectory = CreateTargetDirectory(); + var firstManifest = CreateManifest("generals-1.08-en", "1.08", "first.dat", "first-hash"); + var secondManifest = CreateManifest("zerohour-1.04-en", "1.04", "second.dat", "second-hash"); + var deliverer = CreateDeliverer(CreateSuccessfulDownloadService().Object); + + try + { + var firstResult = await deliverer.DeliverContentAsync(firstManifest, targetDirectory); + var secondResult = await deliverer.DeliverContentAsync(secondManifest, targetDirectory); + + firstResult.Data.Should().BeSameAs(firstManifest); + secondResult.Data.Should().BeSameAs(secondManifest); + secondResult.Data!.Files.Should().ContainSingle(f => f.RelativePath == "second.dat"); + secondResult.Data.Files.Should().NotContain(f => f.RelativePath == "first.dat"); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Verifies that user cancellation remains an . + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeliverContentAsync_WhenCancelled_PropagatesCancellationAsync() + { + var targetDirectory = CreateTargetDirectory(); + var manifest = CreateManifest("generals-1.08-en", "1.08", "game.dat", "hash"); + var deliverer = CreateDeliverer(Mock.Of()); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + try + { + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync( + manifest, + targetDirectory, + cancellationToken: cancellationSource.Token)); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Verifies that a manifest file cannot escape the delivery target directory. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeliverContentAsync_WithEscapingPath_ReturnsFailureAsync() + { + var rootDirectory = CreateTargetDirectory(); + var targetDirectory = Path.Combine(rootDirectory, "target"); + Directory.CreateDirectory(targetDirectory); + var manifest = CreateManifest("generals-1.08-en", "1.08", "../escaped.dat", "hash"); + var downloadService = new Mock(); + var deliverer = CreateDeliverer(downloadService.Object); + + try + { + var result = await deliverer.DeliverContentAsync(manifest, targetDirectory); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Contain("resolves outside target directory"); + File.Exists(Path.Combine(rootDirectory, "escaped.dat")).Should().BeFalse(); + downloadService.Verify( + d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + private static HttpContentDeliverer CreateDeliverer(IDownloadService downloadService) => + new(downloadService, Mock.Of>()); + + private static Mock CreateSuccessfulDownloadService() + { + var downloadService = new Mock(); + downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destinationPath, string? _, IProgress? _, CancellationToken _) => + { + File.WriteAllText(destinationPath, "content"); + return Task.FromResult(DownloadResult.CreateSuccess( + destinationPath, + new FileInfo(destinationPath).Length, + TimeSpan.FromMilliseconds(1), + hashVerified: true)); + }); + + return downloadService; + } + + private static ContentManifest CreateManifest( + string contentName, + string version, + string relativePath, + string hash) + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId( + PublisherTypeConstants.CsvRegistry, + ContentType.GameInstallation, + contentName); + + return new ContentManifest + { + Id = new ManifestId(manifestId), + Name = contentName, + Version = version, + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + OriginalProviderName = CsvConstants.SourceName, + OriginalContentId = contentName, + Files = + [ + new ManifestFile + { + RelativePath = relativePath, + SourceType = ContentSourceType.RemoteDownload, + InstallTarget = ContentInstallTarget.Workspace, + Size = 7, + Hash = hash, + DownloadUrl = "https://example.com/game.dat", + IsRequired = false, + IsExecutable = true, + Permissions = new FilePermissions { UnixPermissions = "755" }, + }, + ], + }; + } + + private static string CreateTargetDirectory() + { + var targetDirectory = Path.Combine( + Path.GetTempPath(), + nameof(HttpContentDelivererTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + return targetDirectory; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs index af4ec4273..328249450 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs @@ -6,7 +6,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; -using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -19,10 +18,9 @@ namespace GenHub.Features.Content.Services.ContentDeliverers; /// Delivers remote HTTP content. /// Pure delivery - downloads and extracts content. /// -public class HttpContentDeliverer(IDownloadService downloadService, IContentManifestBuilder manifestBuilder, ILogger logger) : IContentDeliverer +public class HttpContentDeliverer(IDownloadService downloadService, ILogger logger) : IContentDeliverer { private readonly IDownloadService _downloadService = downloadService; - private readonly IContentManifestBuilder _manifestBuilder = manifestBuilder; private readonly ILogger _logger = logger; /// @@ -56,41 +54,6 @@ public async Task> DeliverContentAsync( { try { - // Extract publisher from the manifest ID (3rd segment) - var idSegments = packageManifest.Id.Value.Split('.'); - var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown"; - - var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0; - var deliveredManifest = _manifestBuilder - .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt) - .WithContentType(packageManifest.ContentType, packageManifest.TargetGame) - .WithPublisher( - packageManifest.Publisher?.Name ?? string.Empty, - packageManifest.Publisher?.Website ?? string.Empty, - packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty) - .WithMetadata( - packageManifest.Metadata?.Description ?? string.Empty, - packageManifest.Metadata?.Tags, - packageManifest.Metadata?.IconUrl ?? string.Empty, - packageManifest.Metadata?.ScreenshotUrls, - packageManifest.Metadata?.ChangelogUrl ?? string.Empty); - - // Add dependencies - foreach (var dep in packageManifest.Dependencies) - { - deliveredManifest.AddDependency( - dep.Id, - dep.Name, - dep.DependencyType, - dep.InstallBehavior, - dep.MinVersion ?? string.Empty, - dep.MaxVersion ?? string.Empty, - dep.CompatibleVersions, - dep.IsExclusive, - dep.ConflictsWith); - } - var filesToDownload = packageManifest.Files.Where(f => !string.IsNullOrEmpty(f.DownloadUrl)).ToList(); var totalFiles = filesToDownload.Count; var processedFiles = 0; @@ -100,7 +63,7 @@ public async Task> DeliverContentAsync( { cancellationToken.ThrowIfCancellationRequested(); - var localPath = Path.Combine(targetDirectory, file.RelativePath); + var localPath = ResolveTargetPath(targetDirectory, file.RelativePath); // Ensure directory exists var directory = Path.GetDirectoryName(localPath); @@ -130,38 +93,17 @@ public async Task> DeliverContentAsync( $"Failed to download {file.RelativePath}: {downloadResult.FirstError}"); } - // Add the delivered file using the builder - await deliveredManifest.AddRemoteFileAsync( - file.RelativePath, - file.DownloadUrl ?? string.Empty, - ContentSourceType.ContentAddressable, - isExecutable: file.IsExecutable, - permissions: file.Permissions); - + cancellationToken.ThrowIfCancellationRequested(); processedFiles++; } - // Add any other files (without DownloadUrl) as-is - foreach (var file in packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl))) - { - await deliveredManifest.AddLocalFileAsync( - file.RelativePath, - file.SourcePath ?? string.Empty, - ContentSourceType.ContentAddressable, - isExecutable: file.IsExecutable, - permissions: file.Permissions); - } - - // Add required directories - deliveredManifest.AddRequiredDirectories([.. packageManifest.RequiredDirectories]); - - // Add installation instructions if present - if (packageManifest.InstallationInstructions != null) - { - deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); - } - - return OperationResult.CreateSuccess(deliveredManifest.Build()); + // Delivery changes filesystem state only. The resolved manifest remains authoritative + // for identity, version, hashes, source types, and installation metadata. + return OperationResult.CreateSuccess(packageManifest); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { @@ -194,4 +136,22 @@ public Task> ValidateContentAsync( return Task.FromResult(OperationResult.CreateFailure($"Validation failed: {ex.Message}")); } } + + private static string ResolveTargetPath(string targetDirectory, string relativePath) + { + var targetRoot = Path.GetFullPath(targetDirectory); + var targetPath = Path.GetFullPath(relativePath, targetRoot); + var relativeTargetPath = Path.GetRelativePath(targetRoot, targetPath); + + if (relativeTargetPath.Equals("..", StringComparison.Ordinal) || + relativeTargetPath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) || + relativeTargetPath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal) || + Path.IsPathRooted(relativeTargetPath)) + { + throw new InvalidOperationException( + $"Content path '{relativePath}' resolves outside target directory."); + } + + return targetPath; + } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs index 4cf5f4b59..d13c52c81 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs @@ -87,13 +87,32 @@ public override async Task> GetValidatedContent } /// - protected override Task> PrepareContentInternalAsync( + protected override async Task> PrepareContentInternalAsync( ContentManifest manifest, string workingDirectory, IProgress? progress, CancellationToken cancellationToken) { Logger.LogDebug("Preparing CSV catalog content for manifest {ManifestId}", manifest.Id); - return Task.FromResult(OperationResult.CreateSuccess(manifest)); + + if (!Deliverer.CanDeliver(manifest)) + { + return OperationResult.CreateFailure( + $"Cannot deliver content for manifest {manifest.Id}"); + } + + var deliveryResult = await Deliverer.DeliverContentAsync( + manifest, + workingDirectory, + progress, + cancellationToken); + + if (!deliveryResult.Success) + { + return OperationResult.CreateFailure( + $"Content delivery failed: {deliveryResult.FirstError}"); + } + + return OperationResult.CreateSuccess(deliveryResult.Data ?? manifest); } }