Add unit tests and implementations for MongoDB index models and OpenAPI metadata

- Implemented `MongoIndexModelTests` to verify index models for various stores.
- Created `OpenApiMetadataFactory` with methods to generate OpenAPI metadata.
- Added tests for `OpenApiMetadataFactory` to ensure expected defaults and URL overrides.
- Introduced `ObserverSurfaceSecrets` and `WebhookSurfaceSecrets` for managing secrets.
- Developed `RuntimeSurfaceFsClient` and `WebhookSurfaceFsClient` for manifest retrieval.
- Added dependency injection tests for `SurfaceEnvironmentRegistration` in both Observer and Webhook contexts.
- Implemented tests for secret resolution in `ObserverSurfaceSecretsTests` and `WebhookSurfaceSecretsTests`.
- Created `EnsureLinkNotMergeCollectionsMigrationTests` to validate MongoDB migration logic.
- Added project files for MongoDB tests and NuGet package mirroring.
This commit is contained in:
master
2025-11-17 21:21:56 +02:00
parent d3128aec24
commit 9075bad2d9
146 changed files with 152183 additions and 82 deletions

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.WebService.Contracts;
public sealed record VexEvidenceChunkResponse(
[property: JsonPropertyName("observationId")] string ObservationId,
[property: JsonPropertyName("linksetId")] string LinksetId,
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("productKey")] string ProductKey,
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("detail")] string? Detail,
[property: JsonPropertyName("scopeScore")] double? ScopeScore,
[property: JsonPropertyName("firstSeen")] DateTimeOffset FirstSeen,
[property: JsonPropertyName("lastSeen")] DateTimeOffset LastSeen,
[property: JsonPropertyName("scope")] VexEvidenceChunkScope Scope,
[property: JsonPropertyName("document")] VexEvidenceChunkDocument Document,
[property: JsonPropertyName("signature")] VexEvidenceChunkSignature? Signature,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
public sealed record VexEvidenceChunkScope(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("cpe")] string? Cpe,
[property: JsonPropertyName("componentIdentifiers")] IReadOnlyList<string> ComponentIdentifiers);
public sealed record VexEvidenceChunkDocument(
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("sourceUri")] string SourceUri,
[property: JsonPropertyName("revision")] string? Revision);
public sealed record VexEvidenceChunkSignature(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("subject")] string? Subject,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
[property: JsonPropertyName("transparencyRef")] string? TransparencyRef);

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -51,11 +52,12 @@ services.AddOptions<ExcititorObservabilityOptions>()
services.AddScoped<ExcititorHealthService>();
services.AddExcititorAocGuards();
services.AddVexExportEngine();
services.AddVexExportCacheServices();
services.AddVexExportCacheServices();
services.AddVexAttestation();
services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client"));
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
services.AddVexPolicy();
services.AddVexPolicy();
services.AddSingleton<IVexEvidenceChunkService, VexEvidenceChunkService>();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
@@ -515,6 +517,69 @@ app.MapGet("/v1/vex/observations/{vulnerabilityId}/{productKey}", async (
return Results.Json(response);
});
app.MapGet("/v1/vex/evidence/chunks", async (
HttpContext context,
[FromServices] IVexEvidenceChunkService chunkService,
[FromServices] IOptions<VexMongoStorageOptions> storageOptions,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var vulnerabilityId = context.Request.Query["vulnerabilityId"].FirstOrDefault();
var productKey = context.Request.Query["productKey"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return ValidationProblem("vulnerabilityId and productKey are required.");
}
var providerFilter = BuildStringFilterSet(context.Request.Query["providerId"]);
var statusFilter = BuildStatusFilter(context.Request.Query["status"]);
var since = ParseSinceTimestamp(context.Request.Query["since"]);
var limit = ResolveLimit(context.Request.Query["limit"], defaultValue: 200, min: 1, max: 500);
var request = new VexEvidenceChunkRequest(
tenant,
vulnerabilityId.Trim(),
productKey.Trim(),
providerFilter,
statusFilter,
since,
limit);
VexEvidenceChunkResult result;
try
{
result = await chunkService.QueryAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
context.Response.Headers["X-Total-Count"] = result.TotalCount.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["X-Truncated"] = result.Truncated ? "true" : "false";
context.Response.ContentType = "application/x-ndjson";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
foreach (var chunk in result.Chunks)
{
var line = JsonSerializer.Serialize(chunk, options);
await context.Response.WriteAsync(line, cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync("\n", cancellationToken).ConfigureAwait(false);
}
return Results.Empty;
});
app.MapPost("/aoc/verify", async (
HttpContext context,
VexAocVerifyRequest? request,

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Services;
internal interface IVexEvidenceChunkService
{
Task<VexEvidenceChunkResult> QueryAsync(VexEvidenceChunkRequest request, CancellationToken cancellationToken);
}
internal sealed record VexEvidenceChunkRequest(
string Tenant,
string VulnerabilityId,
string ProductKey,
ImmutableHashSet<string> ProviderIds,
ImmutableHashSet<VexClaimStatus> Statuses,
DateTimeOffset? Since,
int Limit);
internal sealed record VexEvidenceChunkResult(
IReadOnlyList<VexEvidenceChunkResponse> Chunks,
bool Truncated,
int TotalCount,
DateTimeOffset GeneratedAtUtc);
internal sealed class VexEvidenceChunkService : IVexEvidenceChunkService
{
private readonly IVexClaimStore _claimStore;
private readonly TimeProvider _timeProvider;
public VexEvidenceChunkService(IVexClaimStore claimStore, TimeProvider timeProvider)
{
_claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<VexEvidenceChunkResult> QueryAsync(VexEvidenceChunkRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var claims = await _claimStore
.FindAsync(request.VulnerabilityId, request.ProductKey, request.Since, cancellationToken)
.ConfigureAwait(false);
var filtered = claims
.Where(claim => MatchesProvider(claim, request.ProviderIds))
.Where(claim => MatchesStatus(claim, request.Statuses))
.OrderByDescending(claim => claim.LastSeen)
.ToList();
var total = filtered.Count;
if (filtered.Count > request.Limit)
{
filtered = filtered.Take(request.Limit).ToList();
}
var chunks = filtered
.Select(MapChunk)
.ToList();
return new VexEvidenceChunkResult(
chunks,
total > request.Limit,
total,
_timeProvider.GetUtcNow());
}
private static bool MatchesProvider(VexClaim claim, ImmutableHashSet<string> providers)
=> providers.Count == 0 || providers.Contains(claim.ProviderId, StringComparer.OrdinalIgnoreCase);
private static bool MatchesStatus(VexClaim claim, ImmutableHashSet<VexClaimStatus> statuses)
=> statuses.Count == 0 || statuses.Contains(claim.Status);
private static VexEvidenceChunkResponse MapChunk(VexClaim claim)
{
var observationId = string.Create(CultureInfo.InvariantCulture, $"{claim.ProviderId}:{claim.Document.Digest}");
var linksetId = string.Create(CultureInfo.InvariantCulture, $"{claim.VulnerabilityId}:{claim.Product.Key}");
var scope = new VexEvidenceChunkScope(
claim.Product.Key,
claim.Product.Name,
claim.Product.Version,
claim.Product.Purl,
claim.Product.Cpe,
claim.Product.ComponentIdentifiers);
var document = new VexEvidenceChunkDocument(
claim.Document.Digest,
claim.Document.Format.ToString().ToLowerInvariant(),
claim.Document.SourceUri.ToString(),
claim.Document.Revision);
var signature = claim.Document.Signature is null
? null
: new VexEvidenceChunkSignature(
claim.Document.Signature.Type,
claim.Document.Signature.Subject,
claim.Document.Signature.Issuer,
claim.Document.Signature.KeyId,
claim.Document.Signature.VerifiedAt,
claim.Document.Signature.TransparencyLogReference);
var scopeScore = claim.Confidence?.Score ?? claim.Signals?.Severity?.Score;
return new VexEvidenceChunkResponse(
observationId,
linksetId,
claim.VulnerabilityId,
claim.Product.Key,
claim.ProviderId,
claim.Status.ToString(),
claim.Justification?.ToString(),
claim.Detail,
scopeScore,
claim.FirstSeen,
claim.LastSeen,
scope,
document,
signature,
claim.AdditionalMetadata);
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Attestation.Tests")]

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Immutable timeline event emitted for ingest/linkset changes with deterministic field ordering.
/// </summary>
public sealed record TimelineEvent
{
public TimelineEvent(
string eventId,
string tenant,
string providerId,
string streamId,
string eventType,
string traceId,
string justificationSummary,
DateTimeOffset createdAt,
string? evidenceHash = null,
string? payloadHash = null,
ImmutableDictionary<string, string>? attributes = null)
{
EventId = Ensure(eventId, nameof(eventId));
Tenant = Ensure(tenant, nameof(tenant)).ToLowerInvariant();
ProviderId = Ensure(providerId, nameof(providerId));
StreamId = Ensure(streamId, nameof(streamId));
EventType = Ensure(eventType, nameof(eventType));
TraceId = Ensure(traceId, nameof(traceId));
JustificationSummary = justificationSummary?.Trim() ?? string.Empty;
EvidenceHash = evidenceHash?.Trim();
PayloadHash = payloadHash?.Trim();
CreatedAt = createdAt;
Attributes = Normalize(attributes);
}
public string EventId { get; }
public string Tenant { get; }
public string ProviderId { get; }
public string StreamId { get; }
public string EventType { get; }
public string TraceId { get; }
public string JustificationSummary { get; }
public string? EvidenceHash { get; }
public string? PayloadHash { get; }
public DateTimeOffset CreatedAt { get; }
public ImmutableDictionary<string, string> Attributes { get; }
private static string Ensure(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} cannot be null or whitespace", name);
}
return value.Trim();
}
private static ImmutableDictionary<string, string> Normalize(ImmutableDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var kv in attributes)
{
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null)
{
continue;
}
builder[kv.Key.Trim()] = kv.Value;
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Aggregation-only attestation payload describing evidence supplier identity and the observation/linkset it covers.
/// Used by Advisory AI / Policy to chain trust without Excititor interpreting verdicts.
/// </summary>
public sealed record VexAttestationPayload
{
public VexAttestationPayload(
string attestationId,
string supplierId,
string observationId,
string linksetId,
string vulnerabilityId,
string productKey,
string? justificationSummary,
DateTimeOffset issuedAt,
ImmutableDictionary<string, string>? metadata = null)
{
AttestationId = EnsureNotNullOrWhiteSpace(attestationId, nameof(attestationId));
SupplierId = EnsureNotNullOrWhiteSpace(supplierId, nameof(supplierId));
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
VulnerabilityId = EnsureNotNullOrWhiteSpace(vulnerabilityId, nameof(vulnerabilityId));
ProductKey = EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
JustificationSummary = TrimToNull(justificationSummary);
IssuedAt = issuedAt.ToUniversalTime();
Metadata = NormalizeMetadata(metadata);
}
public string AttestationId { get; }
public string SupplierId { get; }
public string ObservationId { get; }
public string LinksetId { get; }
public string VulnerabilityId { get; }
public string ProductKey { get; }
public string? JustificationSummary { get; }
public DateTimeOffset IssuedAt { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
var key = TrimToNull(pair.Key);
var value = TrimToNull(pair.Value);
if (key is null || value is null)
{
continue;
}
builder[key] = value;
}
return builder.ToImmutable();
}
private static string EnsureNotNullOrWhiteSpace(string value, string name)
=> string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} must be provided.", name) : value.Trim();
private static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Lightweight mapping from attestation IDs back to the observation/linkset/product tuple for provenance tracing.
/// </summary>
public sealed record VexAttestationLink
{
public VexAttestationLink(string attestationId, string observationId, string linksetId, string productKey)
{
AttestationId = EnsureNotNullOrWhiteSpace(attestationId, nameof(attestationId));
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
ProductKey = EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
}
public string AttestationId { get; }
public string ObservationId { get; }
public string LinksetId { get; }
public string ProductKey { get; }
private static string EnsureNotNullOrWhiteSpace(string value, string name)
=> string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} must be provided.", name) : value.Trim();
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public interface IVexAttestationLinkStore
{
ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken);
ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
public sealed class MongoVexAttestationLinkStore : IVexAttestationLinkStore
{
private readonly IMongoCollection<VexAttestationLinkRecord> _collection;
public MongoVexAttestationLinkStore(IMongoDatabase database)
{
ArgumentNullException.ThrowIfNull(database);
VexMongoMappingRegistry.Register();
_collection = database.GetCollection<VexAttestationLinkRecord>(VexMongoCollectionNames.Attestations);
}
public async ValueTask UpsertAsync(VexAttestationPayload payload, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(payload);
var record = VexAttestationLinkRecord.FromDomain(payload);
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, record.AttestationId);
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(filter, record, options, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<VexAttestationPayload?> FindAsync(string attestationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
throw new ArgumentException("Attestation id must be provided.", nameof(attestationId));
}
var filter = Builders<VexAttestationLinkRecord>.Filter.Eq(x => x.AttestationId, attestationId.Trim());
var record = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return record?.ToDomain();
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo;
[BsonIgnoreExtraElements]
internal sealed class VexAttestationLinkRecord
{
[BsonId]
public string AttestationId { get; set; } = default!;
public string SupplierId { get; set; } = default!;
public string ObservationId { get; set; } = default!;
public string LinksetId { get; set; } = default!;
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public string? JustificationSummary { get; set; }
= null;
public DateTime IssuedAt { get; set; }
= DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public static VexAttestationLinkRecord FromDomain(VexAttestationPayload payload)
=> new()
{
AttestationId = payload.AttestationId,
SupplierId = payload.SupplierId,
ObservationId = payload.ObservationId,
LinksetId = payload.LinksetId,
VulnerabilityId = payload.VulnerabilityId,
ProductKey = payload.ProductKey,
JustificationSummary = payload.JustificationSummary,
IssuedAt = payload.IssuedAt.UtcDateTime,
Metadata = payload.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal),
};
public VexAttestationPayload ToDomain()
{
var metadata = (Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal))
.ToImmutableDictionary(StringComparer.Ordinal);
return new VexAttestationPayload(
AttestationId,
SupplierId,
ObservationId,
LinksetId,
VulnerabilityId,
ProductKey,
JustificationSummary,
new DateTimeOffset(DateTime.SpecifyKind(IssuedAt, DateTimeKind.Utc)),
metadata);
}
}

View File

@@ -70,10 +70,11 @@ public static class VexMongoCollectionNames
public const string Providers = "vex.providers";
public const string Raw = "vex.raw";
public const string Statements = "vex.statements";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Exports = "vex.exports";
public const string Cache = "vex.cache";
public const string ConnectorState = "vex.connector_state";
public const string ConsensusHolds = "vex.consensus_holds";
public const string Attestations = "vex.attestations";
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Observations;
public class TimelineEventTests
{
[Fact]
public void Normalizes_and_requires_fields()
{
var evt = new TimelineEvent(
eventId: " EVT-1 ",
tenant: "TenantA",
providerId: "prov",
streamId: "stream",
eventType: "ingest",
traceId: "trace-123",
justificationSummary: " summary ",
createdAt: DateTimeOffset.UnixEpoch,
evidenceHash: " evhash ",
payloadHash: " pwhash ",
attributes: ImmutableDictionary<string, string>.Empty.Add(" a ", " b " ));
evt.EventId.Should().Be("EVT-1");
evt.Tenant.Should().Be("tenanta");
evt.JustificationSummary.Should().Be("summary");
evt.EvidenceHash.Should().Be("evhash");
evt.PayloadHash.Should().Be("pwhash");
evt.Attributes.Should().ContainKey("a");
}
[Fact]
public void Throws_on_missing_required()
{
Action act = () => new TimelineEvent(" ", "t", "p", "s", "t", "trace", "", DateTimeOffset.UtcNow);
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexAttestationPayloadTests
{
[Fact]
public void Payload_NormalizesAndOrdersMetadata()
{
var metadata = ImmutableDictionary<string, string>.Empty
.Add(b,

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexAttestationLinkEndpointTests : IDisposable
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexAttestationLinkEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[Excititor:Storage:Mongo:ConnectionString] = _runner.ConnectionString,
[Excititor:Storage:Mongo:DatabaseName] = vex_attestation_links,
[Excititor:Storage:Mongo:DefaultTenant] = tests,
});
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddTestAuthentication();
});
SeedLink();
}
[Fact]
public async Task GetAttestationLink_ReturnsPayload()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(Bearer, vex.read);
var response = await client.GetAsync(/v1/vex/attestations/att-123);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<VexAttestationPayload>();
Assert.NotNull(payload);
Assert.Equal(att-123, payload!.AttestationId);
Assert.Equal(supplier-a, payload.SupplierId);
Assert.Equal(CVE-2025-0001, payload.VulnerabilityId);
Assert.Equal(pkg:demo, payload.ProductKey);
}
private void SeedLink()
{
var client = new MongoDB.Driver.MongoClient(_runner.ConnectionString);
var database = client.GetDatabase(vex_attestation_links);
var collection = database.GetCollection<VexAttestationLinkRecord>(VexMongoCollectionNames.Attestations);
var record = new VexAttestationLinkRecord
{
AttestationId = att-123,
SupplierId = supplier-a,
ObservationId = obs-1,
LinksetId = link-1,
VulnerabilityId = CVE-2025-0001,
ProductKey = pkg:demo,
JustificationSummary = summary,
IssuedAt = DateTime.UtcNow,
Metadata = new Dictionary<string, string> { [policyRevisionId] = rev-1 },
};
collection.InsertOne(record);
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}

View File

@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexEvidenceChunkServiceTests
{
[Fact]
public async Task QueryAsync_FiltersAndLimitsResults()
{
var now = new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero);
var claims = new[]
{
CreateClaim("provider-a", VexClaimStatus.Affected, now.AddHours(-6), now.AddHours(-5), score: 0.9),
CreateClaim("provider-b", VexClaimStatus.NotAffected, now.AddHours(-4), now.AddHours(-3), score: 0.2)
};
var service = new VexEvidenceChunkService(new FakeClaimStore(claims), new FixedTimeProvider(now));
var request = new VexEvidenceChunkRequest(
Tenant: "tenant-a",
VulnerabilityId: "CVE-2025-0001",
ProductKey: "pkg:docker/demo",
ProviderIds: ImmutableHashSet.Create("provider-b"),
Statuses: ImmutableHashSet.Create(VexClaimStatus.NotAffected),
Since: null,
Limit: 1);
var result = await service.QueryAsync(request, CancellationToken.None);
result.Truncated.Should().BeTrue();
result.TotalCount.Should().Be(1);
result.GeneratedAtUtc.Should().Be(now);
var chunk = result.Chunks.Single();
chunk.ProviderId.Should().Be("provider-b");
chunk.Status.Should().Be(VexClaimStatus.NotAffected.ToString());
chunk.ScopeScore.Should().Be(0.2);
chunk.ObservationId.Should().Contain("provider-b");
chunk.Document.Digest.Should().NotBeNullOrWhiteSpace();
}
private static VexClaim CreateClaim(string providerId, VexClaimStatus status, DateTimeOffset firstSeen, DateTimeOffset lastSeen, double? score)
{
var product = new VexProduct("pkg:docker/demo", "demo", "1.0.0", "pkg:docker/demo:1.0.0", null, new[] { "component-a" });
var document = new VexClaimDocument(
VexDocumentFormat.SbomCycloneDx,
digest: Guid.NewGuid().ToString("N"),
sourceUri: new Uri("https://example.test/vex.json"),
revision: "r1",
signature: new VexSignatureMetadata("cosign", "demo", "issuer", keyId: "kid", verifiedAt: firstSeen, transparencyLogReference: null));
var signals = score.HasValue
? new VexSignalSnapshot(new VexSeveritySignal("cvss", score, "low", vector: null), Kev: null, Epss: null)
: null;
return new VexClaim(
"CVE-2025-0001",
providerId,
product,
status,
document,
firstSeen,
lastSeen,
justification: VexJustification.ComponentNotPresent,
detail: "demo detail",
confidence: null,
signals: signals,
additionalMetadata: ImmutableDictionary<string, string>.Empty);
}
private sealed class FakeClaimStore : IVexClaimStore
{
private readonly IReadOnlyCollection<VexClaim> _claims;
public FakeClaimStore(IReadOnlyCollection<VexClaim> claims)
{
_claims = claims;
}
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
var query = _claims
.Where(claim => claim.VulnerabilityId == vulnerabilityId)
.Where(claim => claim.Product.Key == productKey);
if (since.HasValue)
{
query = query.Where(claim => claim.LastSeen >= since.Value);
}
return ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(query.ToList());
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _timestamp;
public FixedTimeProvider(DateTimeOffset timestamp)
{
_timestamp = timestamp;
}
public override DateTimeOffset GetUtcNow() => _timestamp;
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexEvidenceChunksEndpointTests : IDisposable
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexEvidenceChunksEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "vex_chunks_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "tests",
});
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddTestAuthentication();
});
SeedStatements();
}
[Fact]
public async Task ChunksEndpoint_Filters_ByProvider_AndStreamsNdjson()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.read");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tests");
var response = await client.GetAsync("/v1/vex/evidence/chunks?vulnerabilityId=CVE-2025-0001&productKey=pkg:docker/demo&providerId=provider-b&limit=1");
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.TryGetValues("Excititor-Results-Truncated", out var truncatedValues));
Assert.Contains("true", truncatedValues, StringComparer.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
var lines = body.Split(n, StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines);
var chunk = JsonSerializer.Deserialize<VexEvidenceChunkResponse>(lines[0], new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(chunk);
Assert.Equal("provider-b", chunk!.ProviderId);
Assert.Equal("NotAffected", chunk.Status);
Assert.Equal("pkg:docker/demo", chunk.Scope.Key);
Assert.Equal("CVE-2025-0001", chunk.VulnerabilityId);
}
private void SeedStatements()
{
var client = new MongoClient(_runner.ConnectionString);
var database = client.GetDatabase("vex_chunks_tests");
var collection = database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements);
var now = DateTimeOffset.UtcNow;
var claims = new[]
{
CreateClaim("provider-a", VexClaimStatus.Affected, now.AddHours(-6), now.AddHours(-5), 0.9),
CreateClaim("provider-b", VexClaimStatus.NotAffected, now.AddHours(-4), now.AddHours(-3), 0.2),
CreateClaim("provider-c", VexClaimStatus.Affected, now.AddHours(-2), now.AddHours(-1), 0.5)
};
var records = claims
.Select(claim => VexStatementRecord.FromDomain(claim, now))
.ToList();
collection.InsertMany(records);
}
private static VexClaim CreateClaim(string providerId, VexClaimStatus status, DateTimeOffset firstSeen, DateTimeOffset lastSeen, double? score)
{
var product = new VexProduct("pkg:docker/demo", "demo", "1.0.0", "pkg:docker/demo:1.0.0", null, new[] { "component-a" });
var document = new VexClaimDocument(
VexDocumentFormat.SbomCycloneDx,
digest: Guid.NewGuid().ToString("N"),
sourceUri: new Uri("https://example.test/vex.json"),
revision: "r1",
signature: new VexSignatureMetadata("cosign", "demo", "issuer", keyId: "kid", verifiedAt: firstSeen, transparencyLogReference: null));
var signals = score.HasValue
? new VexSignalSnapshot(new VexSeveritySignal("cvss", score, "low", vector: null), Kev: null, Epss: null)
: null;
return new VexClaim(
"CVE-2025-0001",
providerId,
product,
status,
document,
firstSeen,
lastSeen,
justification: VexJustification.ComponentNotPresent,
detail: "demo detail",
confidence: null,
signals: signals,
additionalMetadata: null);
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}