diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/LayerSbomCommandTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/LayerSbomCommandTests.cs new file mode 100644 index 000000000..818f5bad2 --- /dev/null +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/LayerSbomCommandTests.cs @@ -0,0 +1,401 @@ +// ----------------------------------------------------------------------------- +// LayerSbomCommandTests.cs +// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api +// Task: T020 - CLI integration tests for layer SBOM commands +// Description: Unit tests for per-layer SBOM and composition recipe CLI commands +// ----------------------------------------------------------------------------- + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Cli.Commands; +using StellaOps.Cli.Configuration; +using StellaOps.TestKit; + +namespace StellaOps.Cli.Tests.Commands; + +/// +/// Unit tests for layer SBOM CLI commands under the scan command. +/// +[Trait("Category", TestCategories.Unit)] +public sealed class LayerSbomCommandTests +{ + private readonly IServiceProvider _services; + private readonly StellaOpsCliOptions _options; + private readonly Option _verboseOption; + + public LayerSbomCommandTests() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(NullLoggerFactory.Instance); + _services = serviceCollection.BuildServiceProvider(); + + _options = new StellaOpsCliOptions + { + BackendUrl = "http://localhost:5070", + }; + + _verboseOption = new Option("--verbose", "-v") { Description = "Enable verbose output" }; + } + + #region layers Command Tests + + [Fact] + public void BuildLayersCommand_CreatesLayersCommand() + { + // Act + var command = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert + Assert.Equal("layers", command.Name); + Assert.Contains("layers", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayersCommand_HasScanIdArgument() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var scanIdArg = command.Arguments.FirstOrDefault(a => a.Name == "scan-id"); + + // Assert + Assert.NotNull(scanIdArg); + } + + [Fact] + public void LayersCommand_HasOutputOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var outputOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--output") || o.Aliases.Contains("-o")); + + // Assert + Assert.NotNull(outputOption); + Assert.Contains("table", outputOption!.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("json", outputOption.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayersCommand_HasVerboseOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var verboseOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--verbose") || o.Aliases.Contains("-v")); + + // Assert + Assert.NotNull(verboseOption); + } + + #endregion + + #region layer-sbom Command Tests + + [Fact] + public void BuildLayerSbomCommand_CreatesLayerSbomCommand() + { + // Act + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert + Assert.Equal("layer-sbom", command.Name); + Assert.Contains("layer", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sbom", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayerSbomCommand_HasScanIdArgument() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var scanIdArg = command.Arguments.FirstOrDefault(a => a.Name == "scan-id"); + + // Assert + Assert.NotNull(scanIdArg); + } + + [Fact] + public void LayerSbomCommand_HasLayerOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var layerOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--layer") || o.Aliases.Contains("-l")); + + // Assert + Assert.NotNull(layerOption); + Assert.Contains("sha256", layerOption!.Description); + } + + [Fact] + public void LayerSbomCommand_LayerOptionIsRequired() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var layerOption = command.Options.First(o => + o.Aliases.Contains("--layer") || o.Aliases.Contains("-l")); + + // Assert - Check via arity (required options have min arity of 1) + Assert.Equal(1, layerOption.Arity.MinimumNumberOfValues); + } + + [Fact] + public void LayerSbomCommand_HasFormatOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var formatOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--format") || o.Aliases.Contains("-f")); + + // Assert + Assert.NotNull(formatOption); + Assert.Contains("cdx", formatOption!.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("spdx", formatOption.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayerSbomCommand_HasOutputOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var outputOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--output") || o.Aliases.Contains("-o")); + + // Assert + Assert.NotNull(outputOption); + } + + #endregion + + #region recipe Command Tests + + [Fact] + public void BuildRecipeCommand_CreatesRecipeCommand() + { + // Act + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert + Assert.Equal("recipe", command.Name); + Assert.Contains("composition", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("recipe", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RecipeCommand_HasScanIdArgument() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var scanIdArg = command.Arguments.FirstOrDefault(a => a.Name == "scan-id"); + + // Assert + Assert.NotNull(scanIdArg); + } + + [Fact] + public void RecipeCommand_HasVerifyOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act - search by name containing "verify" + var verifyOption = command.Options.FirstOrDefault(o => + o.Name.Contains("verify", StringComparison.OrdinalIgnoreCase) || + o.Aliases.Any(a => a.Contains("verify", StringComparison.OrdinalIgnoreCase))); + + // Assert + Assert.NotNull(verifyOption); + Assert.Contains("Merkle", verifyOption!.Description); + } + + [Fact] + public void RecipeCommand_HasOutputOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var outputOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--output") || o.Aliases.Contains("-o")); + + // Assert + Assert.NotNull(outputOption); + } + + [Fact] + public void RecipeCommand_HasFormatOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var formatOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--format") || o.Aliases.Contains("-f")); + + // Assert + Assert.NotNull(formatOption); + Assert.Contains("json", formatOption!.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("summary", formatOption.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RecipeCommand_HasVerboseOption() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + var verboseOption = command.Options.FirstOrDefault(o => + o.Aliases.Contains("--verbose") || o.Aliases.Contains("-v")); + + // Assert + Assert.NotNull(verboseOption); + } + + #endregion + + #region Command Hierarchy Tests + + [Fact] + public void LayersCommand_ShouldBeAddableToParentCommand() + { + // Arrange + var scanCommand = new Command("scan", "Scanner operations"); + var layersCommand = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + scanCommand.Add(layersCommand); + + // Assert + Assert.Contains(scanCommand.Subcommands, c => c.Name == "layers"); + } + + [Fact] + public void LayerSbomCommand_ShouldBeAddableToParentCommand() + { + // Arrange + var scanCommand = new Command("scan", "Scanner operations"); + var layerSbomCommand = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + scanCommand.Add(layerSbomCommand); + + // Assert + Assert.Contains(scanCommand.Subcommands, c => c.Name == "layer-sbom"); + } + + [Fact] + public void RecipeCommand_ShouldBeAddableToParentCommand() + { + // Arrange + var scanCommand = new Command("scan", "Scanner operations"); + var recipeCommand = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + scanCommand.Add(recipeCommand); + + // Assert + Assert.Contains(scanCommand.Subcommands, c => c.Name == "recipe"); + } + + [Fact] + public void AllCommands_CanCoexistUnderSameParent() + { + // Arrange + var scanCommand = new Command("scan", "Scanner operations"); + var layersCommand = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + var layerSbomCommand = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + var recipeCommand = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Act + scanCommand.Add(layersCommand); + scanCommand.Add(layerSbomCommand); + scanCommand.Add(recipeCommand); + + // Assert + Assert.Equal(3, scanCommand.Subcommands.Count); + Assert.Contains(scanCommand.Subcommands, c => c.Name == "layers"); + Assert.Contains(scanCommand.Subcommands, c => c.Name == "layer-sbom"); + Assert.Contains(scanCommand.Subcommands, c => c.Name == "recipe"); + } + + #endregion + + #region Handler Existence Tests + + [Fact] + public void LayersCommand_HasHandler() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayersCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert - Handler is set via SetAction + Assert.NotNull(command); + } + + [Fact] + public void LayerSbomCommand_HasHandler() + { + // Arrange + var command = LayerSbomCommandGroup.BuildLayerSbomCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert - Handler is set via SetAction + Assert.NotNull(command); + } + + [Fact] + public void RecipeCommand_HasHandler() + { + // Arrange + var command = LayerSbomCommandGroup.BuildRecipeCommand( + _services, _options, _verboseOption, CancellationToken.None); + + // Assert - Handler is set via SetAction + Assert.NotNull(command); + } + + #endregion +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs index 23b62c1d9..58f863b1b 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs @@ -88,6 +88,7 @@ internal static class ScanEndpoints scans.MapEvidenceEndpoints(); scans.MapApprovalEndpoints(); scans.MapManifestEndpoints(); + scans.MapLayerSbomEndpoints(); // Sprint: SPRINT_20260106_003_001 } private static async Task HandleSubmitAsync( diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 74c18a017..764c88650 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -142,6 +142,7 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Sprint: SPRINT_20260106_003_001 builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/LayerSbomEndpointsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/LayerSbomEndpointsTests.cs index fb6cbb0b9..a797a4762 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/LayerSbomEndpointsTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/LayerSbomEndpointsTests.cs @@ -5,13 +5,11 @@ // Description: Integration tests for per-layer SBOM and composition recipe endpoints. // ----------------------------------------------------------------------------- -using System.Collections.Concurrent; using System.Collections.Immutable; using System.Net; using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using StellaOps.Scanner.Emit.Composition; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; @@ -30,29 +28,30 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task ListLayers_WhenScanExists_ReturnsLayers() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(3)); - var stubCoordinator = new StubScanCoordinator(); - stubCoordinator.AddScan(scanId, "sha256:image123"); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); - services.RemoveAll(); services.AddSingleton(mockService); - services.AddSingleton(stubCoordinator); }); using var client = factory.CreateClient(); + // Submit scan via HTTP POST to get scan ID + var scanId = await SubmitScanAsync(client, imageDigest); + + // Set up the mock layer service with the generated scan ID + mockService.AddScan(scanId, imageDigest, CreateTestLayers(3)); + var response = await client.GetAsync($"{BasePath}/{scanId}/layers"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(scanId, result!.ScanId); - Assert.Equal("sha256:image123", result.ImageDigest); + Assert.Equal(imageDigest, result.ImageDigest); Assert.Equal(3, result.Layers.Count); Assert.All(result.Layers, l => Assert.True(l.HasSbom)); } @@ -60,10 +59,10 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task ListLayers_WhenScanNotFound_Returns404() { + using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(); }); using var client = factory.CreateClient(); @@ -76,27 +75,26 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task ListLayers_LayersOrderedByOrder() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); + + using var factory = new ScannerApplicationFactory() + .WithOverrides(configureServices: services => + { + services.AddSingleton(mockService); + }); + using var client = factory.CreateClient(); + + var scanId = await SubmitScanAsync(client, imageDigest); + var layers = new[] { CreateLayerSummary("sha256:layer2", 2, 15), CreateLayerSummary("sha256:layer0", 0, 42), CreateLayerSummary("sha256:layer1", 1, 8), }; - mockService.AddScan(scanId, "sha256:image123", layers); - var stubCoordinator = new StubScanCoordinator(); - stubCoordinator.AddScan(scanId, "sha256:image123"); - - using var factory = new ScannerApplicationFactory() - .WithOverrides(configureServices: services => - { - services.RemoveAll(); - services.RemoveAll(); - services.AddSingleton(mockService); - services.AddSingleton(stubCoordinator); - }); - using var client = factory.CreateClient(); + mockService.AddScan(scanId, imageDigest, layers); var response = await client.GetAsync($"{BasePath}/{scanId}/layers"); @@ -116,25 +114,23 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetLayerSbom_DefaultFormat_ReturnsCycloneDx() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); - var layerDigest = "sha256:layer123"; + const string imageDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + const string layerDigest = "sha256:layer123"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest)); - mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); - mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx")); - var stubCoordinator = new StubScanCoordinator(); - stubCoordinator.AddScan(scanId, "sha256:image123"); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); - services.RemoveAll(); services.AddSingleton(mockService); - services.AddSingleton(stubCoordinator); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(1, layerDigest)); + mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); + mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx")); + var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -146,25 +142,23 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetLayerSbom_SpdxFormat_ReturnsSpdx() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); - var layerDigest = "sha256:layer123"; + const string imageDigest = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + const string layerDigest = "sha256:layer123"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest)); - mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); - mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx")); - var stubCoordinator = new StubScanCoordinator(); - stubCoordinator.AddScan(scanId, "sha256:image123"); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); - services.RemoveAll(); services.AddSingleton(mockService); - services.AddSingleton(stubCoordinator); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(1, layerDigest)); + mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); + mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx")); + var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom?format=spdx"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -176,24 +170,22 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetLayerSbom_SetsImmutableCacheHeaders() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); - var layerDigest = "sha256:layer123"; + const string imageDigest = "sha256:4444444444444444444444444444444444444444444444444444444444444444"; + const string layerDigest = "sha256:layer123"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest)); - mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); - var stubCoordinator = new StubScanCoordinator(); - stubCoordinator.AddScan(scanId, "sha256:image123"); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); - services.RemoveAll(); services.AddSingleton(mockService); - services.AddSingleton(stubCoordinator); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(1, layerDigest)); + mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx")); + var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -206,10 +198,10 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetLayerSbom_WhenScanNotFound_Returns404() { + using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(); }); using var client = factory.CreateClient(); @@ -222,18 +214,20 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetLayerSbom_WhenLayerNotFound_Returns404() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:5555555555555555555555555555555555555555555555555555555555555555"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1)); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(mockService); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(1)); + var response = await client.GetAsync($"{BasePath}/{scanId}/layers/sha256:nonexistent/sbom"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -246,26 +240,28 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetCompositionRecipe_WhenExists_ReturnsRecipe() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:6666666666666666666666666666666666666666666666666666666666666666"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); - mockService.AddCompositionRecipe(scanId, CreateTestRecipe(scanId, "sha256:image123", 2)); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(mockService); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(2)); + mockService.AddCompositionRecipe(scanId, CreateTestRecipe(scanId, imageDigest, 2)); + var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(scanId, result!.ScanId); - Assert.Equal("sha256:image123", result.ImageDigest); + Assert.Equal(imageDigest, result.ImageDigest); Assert.NotNull(result.Recipe); Assert.Equal(2, result.Recipe.Layers.Count); Assert.NotNull(result.Recipe.MerkleRoot); @@ -274,10 +270,10 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetCompositionRecipe_WhenScanNotFound_Returns404() { + using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(); }); using var client = factory.CreateClient(); @@ -290,19 +286,21 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task GetCompositionRecipe_WhenRecipeNotAvailable_Returns404() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:7777777777777777777777777777777777777777777777777777777777777777"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1)); - // Note: not adding composition recipe using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(mockService); }); using var client = factory.CreateClient(); + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(1)); + // Note: not adding composition recipe + var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -315,9 +313,19 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task VerifyCompositionRecipe_WhenValid_ReturnsSuccess() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:8888888888888888888888888888888888888888888888888888888888888888"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); + + using var factory = new ScannerApplicationFactory() + .WithOverrides(configureServices: services => + { + services.AddSingleton(mockService); + }); + using var client = factory.CreateClient(); + + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(2)); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = true, @@ -326,14 +334,6 @@ public sealed class LayerSbomEndpointsTests Errors = ImmutableArray.Empty, }); - using var factory = new ScannerApplicationFactory() - .WithOverrides(configureServices: services => - { - services.RemoveAll(); - services.AddSingleton(mockService); - }); - using var client = factory.CreateClient(); - var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -348,9 +348,19 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task VerifyCompositionRecipe_WhenInvalid_ReturnsErrors() { - var scanId = "scan-" + Guid.NewGuid().ToString("N"); + const string imageDigest = "sha256:9999999999999999999999999999999999999999999999999999999999999999"; + using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); - mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); + + using var factory = new ScannerApplicationFactory() + .WithOverrides(configureServices: services => + { + services.AddSingleton(mockService); + }); + using var client = factory.CreateClient(); + + var scanId = await SubmitScanAsync(client, imageDigest); + mockService.AddScan(scanId, imageDigest, CreateTestLayers(2)); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = false, @@ -359,14 +369,6 @@ public sealed class LayerSbomEndpointsTests Errors = ImmutableArray.Create("Merkle root mismatch: expected sha256:abc, got sha256:def"), }); - using var factory = new ScannerApplicationFactory() - .WithOverrides(configureServices: services => - { - services.RemoveAll(); - services.AddSingleton(mockService); - }); - using var client = factory.CreateClient(); - var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -382,10 +384,10 @@ public sealed class LayerSbomEndpointsTests [Fact] public async Task VerifyCompositionRecipe_WhenScanNotFound_Returns404() { + using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { - services.RemoveAll(); services.AddSingleton(); }); using var client = factory.CreateClient(); @@ -399,6 +401,19 @@ public sealed class LayerSbomEndpointsTests #region Test Helpers + private static async Task SubmitScanAsync(HttpClient client, string imageDigest) + { + var submitRequest = new ScanSubmitRequest + { + Image = new ScanImageDescriptor { Digest = imageDigest } + }; + var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", submitRequest); + Assert.Equal(HttpStatusCode.Accepted, submitResponse.StatusCode); + var submitResult = await submitResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(submitResult); + return submitResult!.ScanId; + } + private static LayerSummary[] CreateTestLayers(int count, string? specificDigest = null) { var layers = new LayerSummary[count]; @@ -573,44 +588,3 @@ internal sealed class InMemoryLayerSbomService : ILayerSbomService return Task.CompletedTask; } } - -/// -/// Stub IScanCoordinator that returns snapshots for registered scans. -/// -internal sealed class StubScanCoordinator : IScanCoordinator -{ - private readonly ConcurrentDictionary _scans = new(StringComparer.OrdinalIgnoreCase); - - public void AddScan(string scanId, string imageDigest) - { - var snapshot = new ScanSnapshot( - ScanId.Parse(scanId), - new ScanTarget("test-image", imageDigest, null), - ScanStatus.Completed, - DateTimeOffset.UtcNow.AddMinutes(-5), - DateTimeOffset.UtcNow, - null, null, null); - _scans[scanId] = snapshot; - } - - public ValueTask SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken) - => throw new NotImplementedException(); - - public ValueTask GetAsync(ScanId scanId, CancellationToken cancellationToken) - { - if (_scans.TryGetValue(scanId.Value, out var snapshot)) - { - return ValueTask.FromResult(snapshot); - } - return ValueTask.FromResult(null); - } - - public ValueTask TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken) - => ValueTask.FromResult(null); - - public ValueTask AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken) - => ValueTask.FromResult(false); - - public ValueTask AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken) - => ValueTask.FromResult(false); -}