Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SbomHotLookupEndpointsTests.cs
2026-02-11 01:32:14 +02:00

214 lines
8.1 KiB
C#

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<SbomHotLookupLatestResponseDto>(
$"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest");
Assert.NotNull(latest);
Assert.Equal(payloadDigest, latest!.PayloadDigest);
var components = await client.GetFromJsonAsync<SbomHotLookupComponentSearchResponseDto>(
"/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<SbomHotLookupPendingSearchResponseDto>(
"/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<SbomHotLookupLatestResponseDto>(
$"/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<SbomHotLookupLatestResponseDto>(
$"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest");
Assert.NotNull(secondLatest);
Assert.Equal(firstLatest!.CanonicalBomSha256, secondLatest!.CanonicalBomSha256);
var componentHits = await client.GetFromJsonAsync<SbomHotLookupComponentSearchResponseDto>(
$"/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<ScannerApplicationFactory> CreateFactoryAsync()
{
var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(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<ScanSubmitResponse>();
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
return (payload.ScanId, payloadDigest);
}
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
{
private readonly ConcurrentDictionary<string, byte[]> _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<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
if (!_objects.TryGetValue($"{descriptor.Bucket}:{descriptor.Key}", out var bytes))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(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;
}
}
}