// ----------------------------------------------------------------------------- // LayerSbomEndpointsTests.cs // Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api // Task: T016 - Integration tests for layer SBOM API // 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; using StellaOps.Scanner.WebService.Services; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; [Trait("Category", TestCategories.Integration)] public sealed class LayerSbomEndpointsTests { private const string BasePath = "/api/v1/scans"; #region List Layers Tests [Fact] public async Task ListLayers_WhenScanExists_ReturnsLayers() { const string imageDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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(imageDigest, result.ImageDigest); Assert.Equal(3, result.Layers.Count); Assert.All(result.Layers, l => Assert.True(l.HasSbom)); } [Fact] public async Task ListLayers_WhenScanNotFound_Returns404() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.GetAsync($"{BasePath}/scan-not-found/layers"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task ListLayers_LayersOrderedByOrder() { const string imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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, imageDigest, layers); var response = await client.GetAsync($"{BasePath}/{scanId}/layers"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); // Verify layer order is as stored (service already orders by Order) Assert.Equal(0, result!.Layers[0].Order); Assert.Equal(1, result.Layers[1].Order); Assert.Equal(2, result.Layers[2].Order); } #endregion #region Get Layer SBOM Tests [Fact] public async Task GetLayerSbom_DefaultFormat_ReturnsCycloneDx() { const string imageDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; const string layerDigest = "sha256:layer123"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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); Assert.Contains("cyclonedx", response.Content.Headers.ContentType?.ToString()); var content = await response.Content.ReadAsStringAsync(); Assert.Contains("cyclonedx", content); } [Fact] public async Task GetLayerSbom_SpdxFormat_ReturnsSpdx() { const string imageDigest = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; const string layerDigest = "sha256:layer123"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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); Assert.Contains("spdx", response.Content.Headers.ContentType?.ToString()); var content = await response.Content.ReadAsStringAsync(); Assert.Contains("spdx", content); } [Fact] public async Task GetLayerSbom_SetsImmutableCacheHeaders() { const string imageDigest = "sha256:4444444444444444444444444444444444444444444444444444444444444444"; const string layerDigest = "sha256:layer123"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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); Assert.NotNull(response.Headers.ETag); Assert.Contains("immutable", response.Headers.CacheControl?.ToString()); Assert.True(response.Headers.Contains("X-StellaOps-Layer-Digest")); Assert.True(response.Headers.Contains("X-StellaOps-Format")); } [Fact] public async Task GetLayerSbom_WhenScanNotFound_Returns404() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.GetAsync($"{BasePath}/scan-not-found/layers/sha256:layer123/sbom"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task GetLayerSbom_WhenLayerNotFound_Returns404() { const string imageDigest = "sha256:5555555555555555555555555555555555555555555555555555555555555555"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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); } #endregion #region Composition Recipe Tests [Fact] public async Task GetCompositionRecipe_WhenExists_ReturnsRecipe() { const string imageDigest = "sha256:6666666666666666666666666666666666666666666666666666666666666666"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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(imageDigest, result.ImageDigest); Assert.NotNull(result.Recipe); Assert.Equal(2, result.Recipe.Layers.Count); Assert.NotNull(result.Recipe.MerkleRoot); } [Fact] public async Task GetCompositionRecipe_WhenScanNotFound_Returns404() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.GetAsync($"{BasePath}/scan-not-found/composition-recipe"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task GetCompositionRecipe_WhenRecipeNotAvailable_Returns404() { const string imageDigest = "sha256:7777777777777777777777777777777777777777777777777777777777777777"; using var secrets = new TestSurfaceSecretsScope(); var mockService = new InMemoryLayerSbomService(); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.AddSingleton(mockService); }); await factory.InitializeAsync(); 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); } #endregion #region Verify Composition Recipe Tests [Fact] public async Task VerifyCompositionRecipe_WhenValid_ReturnsSuccess() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var mockService = new InMemoryLayerSbomService(); var coordinator = new StubScanCoordinator(); mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); coordinator.AddScan(scanId, "sha256:image123"); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = true, MerkleRootMatch = true, LayerDigestsMatch = true, Errors = ImmutableArray.Empty, }); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(mockService); services.RemoveAll(); services.AddSingleton(coordinator); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.True(result!.Valid); Assert.True(result.MerkleRootMatch); Assert.True(result.LayerDigestsMatch); Assert.Null(result.Errors); } [Fact] public async Task VerifyCompositionRecipe_WhenInvalid_ReturnsErrors() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var mockService = new InMemoryLayerSbomService(); var coordinator = new StubScanCoordinator(); mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); coordinator.AddScan(scanId, "sha256:image123"); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = false, MerkleRootMatch = false, LayerDigestsMatch = true, Errors = ImmutableArray.Create("Merkle root mismatch: expected sha256:abc, got sha256:def"), }); await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(mockService); services.RemoveAll(); services.AddSingleton(coordinator); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.False(result!.Valid); Assert.False(result.MerkleRootMatch); Assert.NotNull(result.Errors); Assert.Single(result.Errors!); Assert.Contains("Merkle root mismatch", result.Errors![0]); } [Fact] public async Task VerifyCompositionRecipe_WhenScanNotFound_Returns404() { await using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(); services.RemoveAll(); services.AddSingleton(); }); await factory.InitializeAsync(); using var client = factory.CreateClient(); var response = await client.PostAsync($"{BasePath}/scan-not-found/composition-recipe/verify", null); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } #endregion #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]; for (int i = 0; i < count; i++) { layers[i] = CreateLayerSummary( i == 0 && specificDigest != null ? specificDigest : $"sha256:layer{i}", i, 10 + i * 5); } return layers; } private static LayerSummary CreateLayerSummary(string digest, int order, int componentCount) { return new LayerSummary { LayerDigest = digest, Order = order, HasSbom = true, ComponentCount = componentCount, }; } private static byte[] CreateTestSbomBytes(string format) { var content = format == "spdx" ? """{"spdxVersion":"SPDX-3.0.1","format":"spdx"}""" : """{"bomFormat":"CycloneDX","specVersion":"1.7","format":"cyclonedx"}"""; return Encoding.UTF8.GetBytes(content); } private static CompositionRecipeResponse CreateTestRecipe(string scanId, string imageDigest, int layerCount) { var layers = new CompositionRecipeLayer[layerCount]; for (int i = 0; i < layerCount; i++) { layers[i] = new CompositionRecipeLayer { Digest = $"sha256:layer{i}", Order = i, FragmentDigest = $"sha256:frag{i}", SbomDigests = new LayerSbomDigests { CycloneDx = $"sha256:cdx{i}", Spdx = $"sha256:spdx{i}", }, ComponentCount = 10 + i * 5, }; } return new CompositionRecipeResponse { ScanId = scanId, ImageDigest = imageDigest, CreatedAt = DateTimeOffset.UtcNow.ToString("O"), Recipe = new CompositionRecipe { Version = "1.0.0", GeneratorName = "StellaOps.Scanner", GeneratorVersion = "2026.04", Layers = layers.ToImmutableArray(), MerkleRoot = "sha256:merkleroot123", AggregatedSbomDigests = new AggregatedSbomDigests { CycloneDx = "sha256:finalcdx", Spdx = "sha256:finalspdx", }, }, }; } #endregion } /// /// In-memory implementation of ILayerSbomService for testing. /// internal sealed class InMemoryLayerSbomService : ILayerSbomService { private readonly Dictionary _scans = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<(string ScanId, string LayerDigest, string Format), byte[]> _layerSboms = new(); private readonly Dictionary _recipes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _verificationResults = new(StringComparer.OrdinalIgnoreCase); public void AddScan(string scanId, string imageDigest, LayerSummary[] layers) { _scans[scanId] = (imageDigest, layers); } public bool HasScan(string scanId) => _scans.ContainsKey(scanId); public (string ImageDigest, LayerSummary[] Layers)? GetScanData(string scanId) { if (_scans.TryGetValue(scanId, out var data)) return data; return null; } public void AddLayerSbom(string scanId, string layerDigest, string format, byte[] sbomBytes) { _layerSboms[(scanId, layerDigest, format)] = sbomBytes; } public void AddCompositionRecipe(string scanId, CompositionRecipeResponse recipe) { _recipes[scanId] = recipe; } public void SetVerificationResult(string scanId, CompositionRecipeVerificationResult result) { _verificationResults[scanId] = result; } public Task> GetLayerSummariesAsync( ScanId scanId, CancellationToken cancellationToken = default) { if (!_scans.TryGetValue(scanId.Value, out var scanData)) { return Task.FromResult(ImmutableArray.Empty); } return Task.FromResult(scanData.Layers.OrderBy(l => l.Order).ToImmutableArray()); } public Task GetLayerSbomAsync( ScanId scanId, string layerDigest, string format, CancellationToken cancellationToken = default) { if (_layerSboms.TryGetValue((scanId.Value, layerDigest, format), out var sbomBytes)) { return Task.FromResult(sbomBytes); } return Task.FromResult(null); } public Task GetCompositionRecipeAsync( ScanId scanId, CancellationToken cancellationToken = default) { if (_recipes.TryGetValue(scanId.Value, out var recipe)) { return Task.FromResult(recipe); } return Task.FromResult(null); } public Task VerifyCompositionRecipeAsync( ScanId scanId, CancellationToken cancellationToken = default) { if (_verificationResults.TryGetValue(scanId.Value, out var result)) { return Task.FromResult(result); } return Task.FromResult(null); } public Task StoreLayerSbomsAsync( ScanId scanId, string imageDigest, LayerSbomCompositionResult result, CancellationToken cancellationToken = default) { // Not implemented for tests return Task.CompletedTask; } public Task GetComposedSbomAsync( ScanId scanId, string format, CancellationToken cancellationToken = default) { // Return the first matching layer SBOM for testing purposes var key = _layerSboms.Keys.FirstOrDefault(k => k.ScanId == scanId.Value && k.Format == format); if (key != default && _layerSboms.TryGetValue(key, out var sbom)) { return Task.FromResult(sbom); } return Task.FromResult(null); } public Task?> GetLayerFragmentsAsync( ScanId scanId, CancellationToken cancellationToken = default) { if (!_scans.TryGetValue(scanId.Value, out var scanData)) { return Task.FromResult?>(null); } var fragments = scanData.Layers .OrderBy(l => l.Order) .Select(l => new SbomLayerFragment { LayerDigest = l.LayerDigest, Order = l.Order, ComponentPurls = new List { $"pkg:test/layer{l.Order}@1.0.0" } }) .ToList(); return Task.FromResult?>(fragments); } } /// /// Stub IScanCoordinator that supports pre-populating scans with specific IDs for testing. /// internal sealed class StubScanCoordinator : IScanCoordinator { private readonly ConcurrentDictionary _scans = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; /// /// Creates a StubScanCoordinator with default TimeProvider.System. /// public StubScanCoordinator() : this(TimeProvider.System) { } /// /// Creates a StubScanCoordinator for DI registration with injected dependencies. /// public StubScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher) : this(timeProvider) { } private StubScanCoordinator(TimeProvider timeProvider) { _timeProvider = timeProvider; } public void AddScan(string scanId, string imageDigest) { var now = _timeProvider.GetUtcNow(); var snapshot = new ScanSnapshot( new ScanId(scanId), new ScanTarget("test-image", imageDigest), ScanStatus.Succeeded, now.AddMinutes(-5), now, null, null, null); _scans[scanId] = snapshot; } public ValueTask SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken) { var now = _timeProvider.GetUtcNow(); var scanId = new ScanId(Guid.NewGuid().ToString("N")); var snapshot = new ScanSnapshot( scanId, submission.Target, ScanStatus.Pending, now, now, null, null, null); _scans[scanId.Value] = snapshot; return ValueTask.FromResult(new ScanSubmissionResult(snapshot, true)); } 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) { foreach (var snapshot in _scans.Values) { if (!string.IsNullOrWhiteSpace(digest) && string.Equals(snapshot.Target.Digest, digest, StringComparison.OrdinalIgnoreCase)) { return ValueTask.FromResult(snapshot); } if (!string.IsNullOrWhiteSpace(reference) && string.Equals(snapshot.Target.Reference, reference, StringComparison.OrdinalIgnoreCase)) { return ValueTask.FromResult(snapshot); } } return 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); }