using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using StellaOps.Scanner.Storage.ObjectStore; using StellaOps.Scanner.WebService.Contracts; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.WebService.Tests; public sealed class SbomHotLookupEndpointsTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task HotLookupEndpoints_ReturnLatestComponentAndPendingRows() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = await CreateFactoryAsync(); using var client = factory.CreateClient(); var (scanId, payloadDigest) = await CreateScanAsync(client, "sha256:hotlookup0001"); var sbomJson = """ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, "components": [ { "type": "library", "name": "openssl", "version": "3.0.12", "purl": "pkg:deb/debian/openssl@3.0.12" } ], "vulnerabilities": [ { "id": "CVE-2026-0001", "analysis": { "state": "triage_pending" }, "affects": [ { "ref": "pkg:deb/debian/openssl@3.0.12" } ] } ] } """; var submitResponse = await client.PostAsync( $"/api/v1/scans/{scanId}/sbom", new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json")); Assert.Equal(HttpStatusCode.Accepted, submitResponse.StatusCode); var latest = await client.GetFromJsonAsync( $"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest"); Assert.NotNull(latest); Assert.Equal(payloadDigest, latest!.PayloadDigest); var components = await client.GetFromJsonAsync( "/api/v1/sbom/hot-lookup/components?purl=pkg:deb/debian/openssl@3.0.12&limit=20"); Assert.NotNull(components); Assert.NotEmpty(components!.Items); var pending = await client.GetFromJsonAsync( "/api/v1/sbom/hot-lookup/pending-triage?limit=20"); Assert.NotNull(pending); Assert.NotEmpty(pending!.Items); Assert.Equal(JsonValueKind.Array, pending.Items[0].Pending.ValueKind); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task HotLookupEndpoints_DuplicateCanonicalPayload_RemainsSingleProjectionRow() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = await CreateFactoryAsync(); using var client = factory.CreateClient(); var (scanId, payloadDigest) = await CreateScanAsync(client, "sha256:hotlookup0002"); const string queryPurl = "pkg:deb/debian/openssl@3.0.12"; var first = """ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, "components": [ { "name": "openssl", "version": "3.0.12", "purl": "pkg:deb/debian/openssl@3.0.12" } ] } """; var second = """ { "specVersion": "1.7", "components": [ { "version": "3.0.12", "purl": "pkg:deb/debian/openssl@3.0.12", "name": "openssl" } ], "version": 1, "bomFormat": "CycloneDX" } """; var firstSubmit = await client.PostAsync( $"/api/v1/scans/{scanId}/sbom", new StringContent(first, Encoding.UTF8, "application/vnd.cyclonedx+json")); Assert.Equal(HttpStatusCode.Accepted, firstSubmit.StatusCode); var firstLatest = await client.GetFromJsonAsync( $"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest"); Assert.NotNull(firstLatest); var secondSubmit = await client.PostAsync( $"/api/v1/scans/{scanId}/sbom", new StringContent(second, Encoding.UTF8, "application/vnd.cyclonedx+json")); Assert.Equal(HttpStatusCode.Accepted, secondSubmit.StatusCode); var secondLatest = await client.GetFromJsonAsync( $"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest"); Assert.NotNull(secondLatest); Assert.Equal(firstLatest!.CanonicalBomSha256, secondLatest!.CanonicalBomSha256); var componentHits = await client.GetFromJsonAsync( $"/api/v1/sbom/hot-lookup/components?purl={queryPurl}"); Assert.NotNull(componentHits); Assert.Single(componentHits!.Items); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task HotLookupEndpoints_InvalidComponentQuery_ReturnsBadRequest() { using var secrets = new TestSurfaceSecretsScope(); await using var factory = await CreateFactoryAsync(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/v1/sbom/hot-lookup/components?purl=a&name=b"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } private static async Task CreateFactoryAsync() { var factory = new ScannerApplicationFactory().WithOverrides(configuration => { configuration["scanner:authority:enabled"] = "false"; }, configureServices: services => { services.RemoveAll(); services.AddSingleton(new InMemoryArtifactObjectStore()); }); await factory.InitializeAsync(); return factory; } private static async Task<(string ScanId, string PayloadDigest)> CreateScanAsync(HttpClient client, string payloadDigest) { var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "example.com/hotlookup:1.0", Digest = payloadDigest } }); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId)); return (payload.ScanId, payloadDigest); } private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore { private readonly ConcurrentDictionary _objects = new(StringComparer.Ordinal); public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(descriptor); ArgumentNullException.ThrowIfNull(content); using var buffer = new MemoryStream(); await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); _objects[$"{descriptor.Bucket}:{descriptor.Key}"] = buffer.ToArray(); } public Task GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(descriptor); if (!_objects.TryGetValue($"{descriptor.Bucket}:{descriptor.Key}", out var bytes)) { return Task.FromResult(null); } return Task.FromResult(new MemoryStream(bytes, writable: false)); } public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(descriptor); _objects.TryRemove($"{descriptor.Bucket}:{descriptor.Key}", out _); return Task.CompletedTask; } } }