Add Layer SBOM endpoints and CLI command tests for integration
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user