using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using StellaOps.Scanner.Storage.ObjectStore; using StellaOps.Scanner.WebService.Contracts; using Xunit; namespace StellaOps.Scanner.WebService.Tests; public sealed class SbomEndpointsTests { [Fact] public async Task SubmitSbomAcceptsCycloneDxJson() { using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory().WithOverrides(configuration => { configuration["scanner:authority:enabled"] = "false"; }, configureServices: services => { services.RemoveAll(); services.AddSingleton(new InMemoryArtifactObjectStore()); }); using var client = factory.CreateClient(); var scanId = await CreateScanAsync(client); var sbomJson = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": [] } """; using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom") { Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json") }; var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload!.SbomId)); Assert.Equal("cyclonedx", payload.Format); Assert.Equal(0, payload.ComponentCount); Assert.StartsWith("sha256:", payload.Digest, StringComparison.Ordinal); } private static async Task CreateScanAsync(HttpClient client) { var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "example.com/demo:1.0", Digest = "sha256:0123456789abcdef" } }); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId)); return payload.ScanId; } 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); var key = $"{descriptor.Bucket}:{descriptor.Key}"; _objects[key] = buffer.ToArray(); } public Task GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(descriptor); var key = $"{descriptor.Bucket}:{descriptor.Key}"; if (!_objects.TryGetValue(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); var key = $"{descriptor.Bucket}:{descriptor.Key}"; _objects.TryRemove(key, out _); return Task.CompletedTask; } } }