using System.Collections.Generic; using System.Formats.Cbor; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.ProofSpine; using Xunit; namespace StellaOps.Scanner.WebService.Tests; public sealed class ProofSpineEndpointsTests { private const string CborContentType = "application/cbor"; [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetSpine_ReturnsSpine_WithVerification() { await using var factory = new ScannerApplicationFactory(); using var scope = factory.Services.CreateScope(); var builder = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); var spine = await builder .ForArtifact("sha256:feedface") .ForVulnerability("CVE-2025-0001") .WithPolicyProfile("default") .WithScanRun("scan-001") .AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0") .AddPolicyEval( policyDigest: "sha256:policy", factors: new Dictionary { ["policy"] = "default" }, verdict: "not_affected", verdictReason: "component_not_present", toolId: "policy", toolVersion: "1.0.0") .BuildAsync(); await repository.SaveAsync(spine); var client = factory.CreateClient(); var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(spine.SpineId, body.GetProperty("spineId").GetString()); var segments = body.GetProperty("segments"); Assert.True(segments.GetArrayLength() > 0); Assert.True(body.TryGetProperty("verification", out _)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetSpine_ReturnsCbor_WhenAcceptHeaderRequestsCbor() { await using var factory = new ScannerApplicationFactory(); using var scope = factory.Services.CreateScope(); var builder = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); var spine = await builder .ForArtifact("sha256:feedface") .ForVulnerability("CVE-2025-1001") .WithPolicyProfile("default") .WithScanRun("scan-cbor-001") .AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0") .BuildAsync(); await repository.SaveAsync(spine); var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/spines/{spine.SpineId}"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType)); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType); var cbor = await response.Content.ReadAsByteArrayAsync(); var decoded = ReadRootMap(cbor); Assert.Equal(spine.SpineId, decoded["spineId"]); Assert.True(decoded.ContainsKey("verification")); Assert.True(((List)decoded["segments"]!).Count > 0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount() { await using var factory = new ScannerApplicationFactory(); using var scope = factory.Services.CreateScope(); var builder = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); var spine = await builder .ForArtifact("sha256:feedface") .ForVulnerability("CVE-2025-0002") .WithPolicyProfile("default") .WithScanRun("scan-002") .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") .AddPolicyEval( policyDigest: "sha256:policy", factors: new Dictionary { ["policy"] = "default" }, verdict: "affected", verdictReason: "reachable", toolId: "policy", toolVersion: "1.0.0") .BuildAsync(); await repository.SaveAsync(spine); var client = factory.CreateClient(); var response = await client.GetAsync("/api/v1/scans/scan-002/spines"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); var items = body.GetProperty("items"); Assert.Equal(1, items.GetArrayLength()); Assert.Equal(spine.SpineId, items[0].GetProperty("spineId").GetString()); Assert.True(items[0].GetProperty("segmentCount").GetInt32() > 0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ListSpinesByScan_ReturnsCbor_WhenAcceptHeaderRequestsCbor() { await using var factory = new ScannerApplicationFactory(); using var scope = factory.Services.CreateScope(); var builder = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); var spine = await builder .ForArtifact("sha256:feedface") .ForVulnerability("CVE-2025-1002") .WithPolicyProfile("default") .WithScanRun("scan-cbor-002") .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") .BuildAsync(); await repository.SaveAsync(spine); var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/scans/scan-cbor-002/spines"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType)); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType); var cbor = await response.Content.ReadAsByteArrayAsync(); var decoded = ReadRootMap(cbor); var items = (List)decoded["items"]!; Assert.Single(items); var first = (Dictionary)items[0]!; Assert.Equal(spine.SpineId, first["spineId"]); Assert.True((int)first["segmentCount"]! > 0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered() { await using var factory = new ScannerApplicationFactory(); using var scope = factory.Services.CreateScope(); using StellaOps.TestKit; var builder = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); var spine = await builder .ForArtifact("sha256:feedface") .ForVulnerability("CVE-2025-0003") .WithPolicyProfile("default") .WithScanRun("scan-003") .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") .AddPolicyEval( policyDigest: "sha256:policy", factors: new Dictionary { ["policy"] = "default" }, verdict: "affected", verdictReason: "reachable", toolId: "policy", toolVersion: "1.0.0") .BuildAsync(); var tamperedSegment = spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" }; var tampered = spine with { Segments = new[] { tamperedSegment, spine.Segments[1] } }; await repository.SaveAsync(tampered); var client = factory.CreateClient(); var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); var segments = body.GetProperty("segments"); Assert.Equal("invalid", segments[0].GetProperty("status").GetString()); } private static Dictionary ReadRootMap(byte[] cbor) { var reader = new CborReader(cbor, CborConformanceMode.Canonical); return ReadMap(reader); } private static Dictionary ReadMap(CborReader reader) { var length = reader.ReadStartMap(); var result = new Dictionary(StringComparer.Ordinal); if (length is not null) { for (var i = 0; i < length.Value; i++) { var key = reader.ReadTextString(); result[key] = ReadValue(reader); } reader.ReadEndMap(); return result; } while (reader.PeekState() != CborReaderState.EndMap) { var key = reader.ReadTextString(); result[key] = ReadValue(reader); } reader.ReadEndMap(); return result; } private static List ReadArray(CborReader reader) { var length = reader.ReadStartArray(); var result = new List(); if (length is not null) { for (var i = 0; i < length.Value; i++) { result.Add(ReadValue(reader)); } reader.ReadEndArray(); return result; } while (reader.PeekState() != CborReaderState.EndArray) { result.Add(ReadValue(reader)); } reader.ReadEndArray(); return result; } private static object? ReadValue(CborReader reader) { switch (reader.PeekState()) { case CborReaderState.StartMap: return ReadMap(reader); case CborReaderState.StartArray: return ReadArray(reader); case CborReaderState.TextString: return reader.ReadTextString(); case CborReaderState.UnsignedInteger: return (int)reader.ReadUInt64(); case CborReaderState.NegativeInteger: return (int)reader.ReadInt64(); case CborReaderState.DoublePrecisionFloat: return reader.ReadDouble(); case CborReaderState.SinglePrecisionFloat: return reader.ReadSingle(); case CborReaderState.HalfPrecisionFloat: return reader.ReadHalf(); case CborReaderState.Boolean: return reader.ReadBoolean(); case CborReaderState.Null: reader.ReadNull(); return null; default: throw new InvalidOperationException($"Unexpected CBOR state {reader.PeekState()}"); } } }