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

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