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);
-}