Add Layer SBOM endpoints and CLI command tests for integration

This commit is contained in:
StellaOps Bot
2026-01-07 21:24:51 +02:00
parent ab364c6032
commit a2070225ce
4 changed files with 507 additions and 130 deletions

View File

@@ -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;
/// <summary>
/// Unit tests for layer SBOM CLI commands under the scan command.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class LayerSbomCommandTests
{
private readonly IServiceProvider _services;
private readonly StellaOpsCliOptions _options;
private readonly Option<bool> _verboseOption;
public LayerSbomCommandTests()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton(NullLoggerFactory.Instance);
_services = serviceCollection.BuildServiceProvider();
_options = new StellaOpsCliOptions
{
BackendUrl = "http://localhost:5070",
};
_verboseOption = new Option<bool>("--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
}

View File

@@ -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<IResult> HandleSubmitAsync(

View File

@@ -142,6 +142,7 @@ builder.Services.AddScoped<ICallGraphIngestionService, CallGraphIngestionService
builder.Services.AddScoped<ISbomIngestionService, SbomIngestionService>();
builder.Services.AddSingleton<ISbomUploadStore, InMemorySbomUploadStore>();
builder.Services.AddScoped<ISbomByosUploadService, SbomByosUploadService>();
builder.Services.AddSingleton<ILayerSbomService, LayerSbomService>(); // Sprint: SPRINT_20260106_003_001
builder.Services.AddSingleton<IPolicySnapshotRepository, InMemoryPolicySnapshotRepository>();
builder.Services.AddSingleton<IPolicyAuditRepository, InMemoryPolicyAuditRepository>();
builder.Services.AddSingleton<PolicySnapshotStore>();

View File

@@ -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<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(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<LayerListResponseDto>();
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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
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<ILayerSbomService>(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<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(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<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(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<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(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<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(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<CompositionRecipeResponseDto>();
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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(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<ILayerSbomService>(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<string>.Empty,
});
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(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<ILayerSbomService>(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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(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<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
using var client = factory.CreateClient();
@@ -399,6 +401,19 @@ public sealed class LayerSbomEndpointsTests
#region Test Helpers
private static async Task<string> 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<ScanSubmitResponse>();
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;
}
}
/// <summary>
/// Stub IScanCoordinator that returns snapshots for registered scans.
/// </summary>
internal sealed class StubScanCoordinator : IScanCoordinator
{
private readonly ConcurrentDictionary<string, ScanSnapshot> _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<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
{
if (_scans.TryGetValue(scanId.Value, out var snapshot))
{
return ValueTask.FromResult<ScanSnapshot?>(snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
=> ValueTask.FromResult<ScanSnapshot?>(null);
public ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken)
=> ValueTask.FromResult(false);
public ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken)
=> ValueTask.FromResult(false);
}