214 lines
8.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|