// ----------------------------------------------------------------------------- // 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); 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(); 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(3, result.Layers.Count); Assert.All(result.Layers, l => Assert.True(l.HasSbom)); } [Fact] public async Task ListLayers_WhenScanNotFound_Returns404() { using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(); }); 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var mockService = new InMemoryLayerSbomService(); 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(); 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var layerDigest = "sha256:layer123"; 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 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var layerDigest = "sha256:layer123"; 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 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); var layerDigest = "sha256:layer123"; 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 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 factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(); }); 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); 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 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); 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 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.NotNull(result.Recipe); Assert.Equal(2, result.Recipe.Layers.Count); Assert.NotNull(result.Recipe.MerkleRoot); } [Fact] public async Task GetCompositionRecipe_WhenScanNotFound_Returns404() { using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(); }); 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() { var scanId = "scan-" + Guid.NewGuid().ToString("N"); 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 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(); mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = true, MerkleRootMatch = true, LayerDigestsMatch = true, 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); 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(); mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2)); mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult { Valid = false, MerkleRootMatch = false, LayerDigestsMatch = true, 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); 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() { using var factory = new ScannerApplicationFactory() .WithOverrides(configureServices: services => { services.RemoveAll(); services.AddSingleton(); }); 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 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; } } /// /// 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); }