Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,38 @@
using Amazon.S3;
using Amazon.S3.Model;
using Moq;
using StellaOps.Excititor.ArtifactStores.S3;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.ArtifactStores.S3.Tests;
public sealed class S3ArtifactClientTests
{
[Fact]
public async Task ObjectExistsAsync_ReturnsTrue_WhenMetadataSucceeds()
{
var mock = new Mock<IAmazonS3>();
mock.Setup(x => x.GetObjectMetadataAsync("bucket", "key", default)).ReturnsAsync(new GetObjectMetadataResponse
{
HttpStatusCode = System.Net.HttpStatusCode.OK,
});
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
var exists = await client.ObjectExistsAsync("bucket", "key", default);
Assert.True(exists);
}
[Fact]
public async Task PutObjectAsync_MapsMetadata()
{
var mock = new Mock<IAmazonS3>();
mock.Setup(x => x.PutObjectAsync(It.IsAny<PutObjectRequest>(), default))
.ReturnsAsync(new PutObjectResponse());
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
await client.PutObjectAsync("bucket", "key", stream, new Dictionary<string, string> { ["a"] = "b" }, default);
mock.Verify(x => x.PutObjectAsync(It.Is<PutObjectRequest>(r => r.Metadata["a"] == "b"), default), Times.Once);
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.ArtifactStores.S3/StellaOps.Excititor.ArtifactStores.S3.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,90 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationClientTests
{
[Fact]
public async Task SignAsync_ReturnsEnvelopeDigestAndDiagnostics()
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var verifier = new FakeVerifier();
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier);
var request = new VexAttestationRequest(
ExportId: "exports/456",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
Assert.NotNull(response.Attestation);
Assert.NotNull(response.Attestation.EnvelopeDigest);
Assert.True(response.Diagnostics.ContainsKey("envelope"));
}
[Fact]
public async Task SignAsync_SubmitsToTransparencyLog_WhenConfigured()
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var transparency = new FakeTransparencyLogClient();
var verifier = new FakeVerifier();
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparencyLogClient: transparency);
var request = new VexAttestationRequest(
ExportId: "exports/789",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
Assert.NotNull(response.Attestation.Rekor);
Assert.True(response.Diagnostics.ContainsKey("rekorLocation"));
Assert.True(transparency.SubmitCalled);
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
{
public bool SubmitCalled { get; private set; }
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
{
SubmitCalled = true;
return ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "23", null));
}
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class FakeVerifier : IVexAttestationVerifier
{
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics["result"]);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
}
[Fact]
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.AllowOfflineTransparency = true;
options.RequireTransparencyLog = true;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("offline", verification.Diagnostics["rekor.state"]);
}
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false)
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var transparency = includeRekor ? new FakeTransparencyLogClient() : null;
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency);
var request = new VexAttestationRequest(
ExportId: "exports/unit-test",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "cafebabe"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("provider-a"),
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
var envelope = response.Diagnostics["envelope"];
return (request, response.Attestation, envelope);
}
private VexAttestationVerifier CreateVerifier(Action<VexAttestationVerificationOptions>? configureOptions = null, ITransparencyLogClient? transparency = null)
{
var options = new VexAttestationVerificationOptions();
configureOptions?.Invoke(options);
return new VexAttestationVerifier(
NullLogger<VexAttestationVerifier>.Instance,
transparency,
Options.Create(options),
_metrics);
}
public void Dispose()
{
_metrics.Dispose();
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null));
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> throw new HttpRequestException("rekor unavailable");
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexDsseBuilderTests
{
[Fact]
public async Task CreateEnvelopeAsync_ProducesDeterministicPayload()
{
var signer = new FakeSigner("signature-value", "key-1");
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var request = new VexAttestationRequest(
ExportId: "exports/123",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var envelope = await builder.CreateEnvelopeAsync(request, request.Metadata, CancellationToken.None);
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
Assert.Single(envelope.Signatures);
Assert.Equal("signature-value", envelope.Signatures[0].Signature);
Assert.Equal("key-1", envelope.Signatures[0].KeyId);
var digest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
Assert.StartsWith("sha256:", digest);
}
private sealed class FakeSigner : IVexSigner
{
private readonly string _signature;
private readonly string _keyId;
public FakeSigner(string signature, string keyId)
{
_signature = signature;
_keyId = keyId;
}
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload(_signature, _keyId));
}
}

View File

@@ -0,0 +1,215 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Cisco.CSAF;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using System.Threading;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Connectors;
public sealed class CiscoCsafConnectorTests
{
[Fact]
public async Task FetchAsync_NewAdvisory_StoresDocumentAndUpdatesState()
{
var responses = new Dictionary<Uri, Queue<HttpResponseMessage>>
{
[new Uri("https://api.cisco.test/.well-known/csaf/provider-metadata.json")] = QueueResponses("""
{
"metadata": {
"publisher": {
"name": "Cisco",
"category": "vendor",
"contact_details": { "id": "excititor:cisco" }
}
},
"distributions": {
"directories": [ "https://api.cisco.test/csaf/" ]
}
}
"""),
[new Uri("https://api.cisco.test/csaf/index.json")] = QueueResponses("""
{
"advisories": [
{
"id": "cisco-sa-2025",
"url": "https://api.cisco.test/csaf/cisco-sa-2025.json",
"published": "2025-10-01T00:00:00Z",
"lastModified": "2025-10-02T00:00:00Z",
"sha256": "cafebabe"
}
]
}
"""),
[new Uri("https://api.cisco.test/csaf/cisco-sa-2025.json")] = QueueResponses("{ \"document\": \"payload\" }")
};
var handler = new RoutingHttpMessageHandler(responses);
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var metadataLoader = new CiscoProviderMetadataLoader(
factory,
new MemoryCache(new MemoryCacheOptions()),
Options.Create(new CiscoConnectorOptions
{
MetadataUri = "https://api.cisco.test/.well-known/csaf/provider-metadata.json",
PersistOfflineSnapshot = false,
}),
NullLogger<CiscoProviderMetadataLoader>.Instance,
new MockFileSystem());
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new CiscoCsafConnector(
metadataLoader,
factory,
stateRepository,
new[] { new CiscoConnectorOptionsValidator() },
NullLogger<CiscoCsafConnector>.Instance,
TimeProvider.System);
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().HaveCount(1);
// second run should not refetch documents
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
}
private static Queue<HttpResponseMessage> QueueResponses(string payload)
=> new(new[]
{
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
}
});
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, Queue<HttpResponseMessage>> _responses;
public RoutingHttpMessageHandler(Dictionary<Uri, Queue<HttpResponseMessage>> responses)
{
_responses = responses;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var queue) && queue.Count > 0)
{
var response = queue.Peek();
return Task.FromResult(response.Clone());
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
internal static class HttpResponseMessageExtensions
{
public static HttpResponseMessage Clone(this HttpResponseMessage response)
{
var clone = new HttpResponseMessage(response.StatusCode);
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (response.Content is not null)
{
var payload = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
clone.Content = new StringContent(payload, Encoding.UTF8, response.Content.Headers.ContentType?.MediaType);
}
return clone;
}
}

View File

@@ -0,0 +1,149 @@
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Metadata;
public sealed class CiscoProviderMetadataLoaderTests
{
[Fact]
public async Task LoadAsync_FetchesFromNetworkWithBearerToken()
{
var payload = """
{
"metadata": {
"publisher": {
"name": "Cisco CSAF",
"category": "vendor",
"contact_details": {
"id": "excititor:cisco"
}
}
},
"distributions": {
"directories": [
"https://api.security.cisco.com/csaf/v2/advisories/"
]
},
"discovery": {
"well_known": "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
"rolie": "https://api.security.cisco.com/csaf/rolie/feed"
},
"trust": {
"weight": 0.9,
"cosign": {
"issuer": "https://oidc.security.cisco.com",
"identity_pattern": "spiffe://cisco/*"
},
"pgp_fingerprints": [ "1234ABCD" ]
}
}
""";
HttpRequestMessage? capturedRequest = null;
var handler = new FakeHttpMessageHandler(request =>
{
capturedRequest = request;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"etag1\"") }
};
});
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new CiscoConnectorOptions
{
ApiToken = "token-123",
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
});
var fileSystem = new MockFileSystem();
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
result.Provider.Id.Should().Be("excititor:cisco");
result.Provider.BaseUris.Should().ContainSingle(uri => uri.ToString() == "https://api.security.cisco.com/csaf/v2/advisories/");
result.Provider.Discovery.RolIeService.Should().Be(new Uri("https://api.security.cisco.com/csaf/rolie/feed"));
result.ServedFromCache.Should().BeFalse();
capturedRequest.Should().NotBeNull();
capturedRequest!.Headers.Authorization.Should().NotBeNull();
capturedRequest.Headers.Authorization!.Parameter.Should().Be("token-123");
}
[Fact]
public async Task LoadAsync_FallsBackToOfflineSnapshot()
{
var payload = """
{
"metadata": {
"publisher": {
"name": "Cisco CSAF",
"category": "vendor",
"contact_details": {
"id": "excititor:cisco"
}
}
}
}
""";
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new CiscoConnectorOptions
{
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
PreferOfflineSnapshot = true,
OfflineSnapshotPath = "/snapshots/cisco.json",
});
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/snapshots/cisco.json"] = new MockFileData(payload),
});
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Provider.Id.Should().Be("excititor:cisco");
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}
}

View File

@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,176 @@
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Authentication;
public sealed class MsrcTokenProviderTests
{
[Fact]
public async Task GetAccessTokenAsync_CachesUntilExpiry()
{
var handler = new TestHttpMessageHandler(new[]
{
CreateTokenResponse("token-1"),
CreateTokenResponse("token-2"),
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
TenantId = "contoso.onmicrosoft.com",
ClientId = "client-id",
ClientSecret = "secret",
});
var timeProvider = new AdjustableTimeProvider();
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
first.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
second.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
}
[Fact]
public async Task GetAccessTokenAsync_RefreshesWhenExpired()
{
var handler = new TestHttpMessageHandler(new[]
{
CreateTokenResponse("token-1", expiresIn: 120),
CreateTokenResponse("token-2", expiresIn: 3600),
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
TenantId = "contoso.onmicrosoft.com",
ClientId = "client-id",
ClientSecret = "secret",
ExpiryLeewaySeconds = 60,
});
var timeProvider = new AdjustableTimeProvider();
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
first.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
timeProvider.Advance(TimeSpan.FromMinutes(2));
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
second.Value.Should().Be("token-2");
handler.InvocationCount.Should().Be(2);
}
[Fact]
public async Task GetAccessTokenAsync_OfflineStaticToken()
{
var factory = Substitute.For<IHttpClientFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
PreferOfflineToken = true,
StaticAccessToken = "offline-token",
});
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
token.Value.Should().Be("offline-token");
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
}
[Fact]
public async Task GetAccessTokenAsync_OfflineFileToken()
{
var factory = Substitute.For<IHttpClientFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine("/tokens", "msrc.txt");
fileSystem.AddFile(offlinePath, new MockFileData("file-token"));
var options = Options.Create(new MsrcConnectorOptions
{
PreferOfflineToken = true,
OfflineTokenPath = offlinePath,
});
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
token.Value.Should().Be("file-token");
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
}
private static HttpResponseMessage CreateTokenResponse(string token, int expiresIn = 3600)
{
var json = $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn}}}";
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
}
private sealed class AdjustableTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan span) => _now = _now.Add(span);
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public TestHttpMessageHandler(IEnumerable<HttpResponseMessage> responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public int InvocationCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
if (_responses.Count == 0)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("no responses remaining"),
});
}
return Task.FromResult(_responses.Dequeue());
}
}
}

View File

@@ -0,0 +1,367 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
public sealed class MsrcCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentAndPersistsState()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"severity": "Critical",
"releaseDate": "2025-10-17T00:00:00Z",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var emitted = documents[0];
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var firstPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
firstPass.Add(document);
}
firstPass.Should().HaveCount(1);
stateRepository.State.Should().NotBeNull();
var persistedState = stateRepository.State!;
handler.Reset(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
sink.Documents.Clear();
var secondPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
secondPass.Add(document);
}
secondPass.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
}
[Fact]
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
{
var summary = """
{
"value": [
{
"id": "ADV-0002",
"vulnerabilityId": "ADV-0002",
"lastModifiedDate": "2025-10-19T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
}
]
}
""";
var csafZip = CreateZip("document.json", "{ invalid json ");
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
{
var response = new HttpResponseMessage(statusCode);
response.Content = new ByteArrayContent(content);
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
return response;
}
private static MsrcConnectorOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
TenantId = Guid.NewGuid().ToString(),
ClientId = "client-id",
ClientSecret = "secret",
Scope = MsrcConnectorOptions.DefaultScope,
PageSize = 5,
MaxAdvisoriesPerFetch = 5,
RequestDelay = TimeSpan.Zero,
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
MaxRetryAttempts = 2,
};
private static byte[] CreateZip(string entryName, string content)
{
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
return buffer.ToArray();
}
private sealed class StubTokenProvider : IMsrcTokenProvider
{
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
{
_responders.Clear();
foreach (var responder in responders)
{
_responders.Enqueue(responder);
}
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
}
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.MSRC.CSAF/StellaOps.Excititor.Connectors.MSRC.CSAF.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,76 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Core;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration;
public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests
{
[Fact]
public void Validate_WithValidConfiguration_Succeeds()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz",
});
options.Registry.Username = "user";
options.Registry.Password = "pass";
options.Cosign.Mode = CosignCredentialMode.None;
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().BeEmpty();
}
[Fact]
public void Validate_WhenImagesMissing_AddsError()
{
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
var options = new OciOpenVexAttestationConnectorOptions();
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured.");
}
[Fact]
public void Validate_WhenDigestMalformed_AddsError()
{
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
var options = new OciOpenVexAttestationConnectorOptions();
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.test/repo/image@sha256:not-a-digest",
});
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().ContainSingle();
}
}

View File

@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
public sealed class OciOpenVexAttestationConnectorTests
{
[Fact]
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
verifier.VerifyCalls.Should().Be(1);
}
[Fact]
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "Keyless")
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
.Add("Cosign:Keyless:Subject", "subject@example.com");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier
{
Result = new VexSignatureMetadata(
type: "cosign",
subject: "sig-subject",
issuer: "sig-issuer",
keyId: "key-id",
verifiedAt: DateTimeOffset.UtcNow,
transparencyLogReference: "rekor://entry/123")
};
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.signature.type", "cosign");
metadata.Should().Contain("vex.signature.subject", "sig-subject");
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
metadata.Should().Contain("vex.signature.keyId", "key-id");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
verifier.VerifyCalls.Should().Be(1);
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
{
public int VerifyCalls { get; private set; }
public VexSignatureMetadata? Result { get; set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
VerifyCalls++;
return ValueTask.FromResult(Result);
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
}
}
}

View File

@@ -0,0 +1,83 @@
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery;
public sealed class OciAttestationDiscoveryServiceTests
{
[Fact]
public async Task LoadAsync_ResolvesOfflinePaths()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
});
options.Offline.RootDirectory = "/bundles";
options.Cosign.Mode = CosignCredentialMode.None;
var result = await service.LoadAsync(options, CancellationToken.None);
result.Targets.Should().ContainSingle();
result.Targets[0].OfflineBundle.Should().NotBeNull();
var offline = result.Targets[0].OfflineBundle!;
offline.Exists.Should().BeTrue();
var expectedPath = fileSystem.Path.Combine(
fileSystem.Path.GetFullPath("/bundles"),
"registry.example.com",
"repo",
"image",
"latest",
"openvex-attestations.tgz");
offline.Path.Should().Be(expectedPath);
}
[Fact]
public async Task LoadAsync_CachesResults()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
});
options.Offline.RootDirectory = "/bundles";
options.Cosign.Mode = CosignCredentialMode.None;
var first = await service.LoadAsync(options, CancellationToken.None);
var second = await service.LoadAsync(options, CancellationToken.None);
ReferenceEquals(first, second).Should().BeTrue();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Connectors;
public sealed class OracleCsafConnectorTests
{
[Fact]
public async Task FetchAsync_NewEntry_PersistsDocumentAndUpdatesState()
{
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var payloadDigest = ComputeDigest(payload);
var snapshotPath = "/snapshots/oracle-catalog.json";
var fileSystem = new MockFileSystem();
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, payloadDigest, "2025-10-15T00:00:00Z")));
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[documentUri] = CreateResponse(payload),
});
var httpClient = new HttpClient(handler);
var httpFactory = new SingleHttpClientFactory(httpClient);
var loader = new OracleCatalogLoader(
httpFactory,
new MemoryCache(new MemoryCacheOptions()),
fileSystem,
NullLogger<OracleCatalogLoader>.Instance,
TimeProvider.System);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new OracleCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { new OracleConnectorOptionsValidator(fileSystem) },
NullLogger<OracleCsafConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: settings,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Digest.Should().Be(payloadDigest);
documents[0].Metadata["oracle.csaf.entryId"].Should().Be("CPU2025Oct");
documents[0].Metadata["oracle.csaf.sha256"].Should().Be(payloadDigest);
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().ContainSingle().Which.Should().Be(payloadDigest);
handler.GetCallCount(documentUri).Should().Be(1);
// second run should short-circuit without downloading again
sink.Documents.Clear();
documents.Clear();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
handler.GetCallCount(documentUri).Should().Be(1);
}
[Fact]
public async Task FetchAsync_ChecksumMismatch_SkipsDocument()
{
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var snapshotPath = "/snapshots/oracle-catalog.json";
var fileSystem = new MockFileSystem();
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, "deadbeef", "2025-10-15T00:00:00Z")));
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[documentUri] = CreateResponse(payload),
});
var httpClient = new HttpClient(handler);
var httpFactory = new SingleHttpClientFactory(httpClient);
var loader = new OracleCatalogLoader(
httpFactory,
new MemoryCache(new MemoryCacheOptions()),
fileSystem,
NullLogger<OracleCatalogLoader>.Instance,
TimeProvider.System);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new OracleCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { new OracleConnectorOptionsValidator(fileSystem) },
NullLogger<OracleCsafConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: settings,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().BeNull();
handler.GetCallCount(documentUri).Should().Be(1);
}
private static HttpResponseMessage CreateResponse(byte[] payload)
=> new(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
{
Headers =
{
ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"),
}
}
};
private static string ComputeDigest(byte[] payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
private static string BuildOfflineSnapshot(Uri documentUri, string sha256, string publishedAt)
{
var snapshot = new
{
metadata = new
{
generatedAt = "2025-10-14T12:00:00Z",
entries = new[]
{
new
{
id = "CPU2025Oct",
title = "Oracle Critical Patch Update Advisory - October 2025",
documentUri = documentUri.ToString(),
publishedAt,
revision = publishedAt,
sha256,
size = 1024,
products = new[] { "Oracle Database" }
}
},
cpuSchedule = Array.Empty<object>()
},
fetchedAt = "2025-10-14T12:00:00Z"
};
return JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
private readonly Dictionary<Uri, int> _callCounts = new();
public StubHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
{
_responses = responses;
}
public int GetCallCount(Uri uri) => _callCounts.TryGetValue(uri, out var count) ? count : 0;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is null || !_responses.TryGetValue(request.RequestUri, out var response))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
_callCounts.TryGetValue(request.RequestUri, out var count);
_callCounts[request.RequestUri] = count + 1;
return Task.FromResult(response.Clone());
}
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
internal static class HttpResponseMessageExtensions
{
public static HttpResponseMessage Clone(this HttpResponseMessage response)
{
var clone = new HttpResponseMessage(response.StatusCode);
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (response.Content is not null)
{
var payload = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
var mediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json";
clone.Content = new ByteArrayContent(payload);
clone.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType);
}
return clone;
}
}

View File

@@ -0,0 +1,205 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using System.Threading;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Metadata;
public sealed class OracleCatalogLoaderTests
{
private const string SampleCatalog = """
{
"generated": "2025-09-30T18:00:00Z",
"catalog": [
{
"id": "CPU2025Oct",
"title": "Oracle Critical Patch Update Advisory - October 2025",
"published": "2025-10-15T00:00:00Z",
"revision": "2025-10-15T00:00:00Z",
"document": {
"url": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
"sha256": "abc123",
"size": 1024
},
"products": ["Oracle Database", "Java SE"]
}
],
"schedule": [
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
]
}
""";
private const string SampleCalendar = """
{
"cpuWindows": [
{ "name": "2026-Jan", "releaseDate": "2026-01-21T00:00:00Z" }
]
}
""";
[Fact]
public async Task LoadAsync_FetchesAndCachesCatalog()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json")] = CreateResponse(SampleCatalog),
[new Uri("https://www.oracle.com/security-alerts/cpu/cal.json")] = CreateResponse(SampleCalendar),
});
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
CpuCalendarUri = new Uri("https://www.oracle.com/security-alerts/cpu/cal.json"),
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.Metadata.Entries.Should().HaveCount(1);
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2025-Oct");
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2026-Jan");
result.FromCache.Should().BeFalse();
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
handler.ResetInvocationCount();
var cached = await loader.LoadAsync(options, CancellationToken.None);
cached.FromCache.Should().BeTrue();
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlineJson = """
{
"metadata": {
"generatedAt": "2025-09-30T18:00:00Z",
"entries": [
{
"id": "CPU2025Oct",
"title": "Oracle Critical Patch Update Advisory - October 2025",
"documentUri": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
"publishedAt": "2025-10-15T00:00:00Z",
"revision": "2025-10-15T00:00:00Z",
"sha256": "abc123",
"size": 1024,
"products": [ "Oracle Database" ]
}
],
"cpuSchedule": [
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
]
},
"fetchedAt": "2025-10-01T00:00:00Z"
}
""";
fileSystem.AddFile("/snapshots/oracle-catalog.json", new MockFileData(offlineJson));
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
PreferOfflineSnapshot = true,
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Metadata.Entries.Should().NotBeEmpty();
}
[Fact]
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance);
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
PreferOfflineSnapshot = true,
OfflineSnapshotPath = "/missing/oracle-catalog.json",
};
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
}
private static HttpResponseMessage CreateResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class AdjustableTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
{
_responses = responses;
}
public int InvocationCount { get; private set; }
public void ResetInvocationCount() => InvocationCount = 0;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("unexpected request"),
};
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Oracle.CSAF/StellaOps.Excititor.Connectors.Oracle.CSAF.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,280 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Connectors;
public sealed class RedHatCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentsAfterSince()
{
var metadata = """
{
"metadata": {
"provider": { "name": "Red Hat Product Security" }
},
"distributions": [
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
],
"rolie": {
"feeds": [
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
]
}
}
""";
var feed = """
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>urn:redhat:1</id>
<updated>2025-10-16T10:00:00Z</updated>
<link href="https://example.com/doc1.json" rel="enclosure" />
</entry>
<entry>
<id>urn:redhat:2</id>
<updated>2025-10-17T10:00:00Z</updated>
<link href="https://example.com/doc2.json" rel="enclosure" />
</entry>
</feed>
""";
var handler = TestHttpMessageHandler.Create(
request => Response(HttpStatusCode.OK, metadata, "application/json"),
request => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
request => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new RedHatConnectorOptions());
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
var rawSink = new CapturingRawSink();
var context = new VexConnectorContext(
new DateTimeOffset(2025, 10, 16, 12, 0, 0, TimeSpan.Zero),
VexConnectorSettings.Empty,
rawSink,
new NoopSignatureVerifier(),
new NoopNormalizerRouter(),
new ServiceCollection().BuildServiceProvider(),
ImmutableDictionary<string, string>.Empty);
var results = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
results.Add(document);
}
Assert.Single(results);
Assert.Single(rawSink.Documents);
Assert.Equal("https://example.com/doc2.json", results[0].SourceUri.ToString());
Assert.Equal("https://example.com/doc2.json", rawSink.Documents[0].SourceUri.ToString());
Assert.Equal(3, handler.CallCount);
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 17, 10, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_UsesStateToSkipDuplicateDocuments()
{
var metadata = """
{
"metadata": {
"provider": { "name": "Red Hat Product Security" }
},
"distributions": [
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
],
"rolie": {
"feeds": [
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
]
}
}
""";
var feed = """
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>urn:redhat:1</id>
<updated>2025-10-17T10:00:00Z</updated>
<link href="https://example.com/doc1.json" rel="enclosure" />
</entry>
</feed>
""";
var handler1 = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
var stateRepository = new InMemoryConnectorStateRepository();
await ExecuteFetchAsync(handler1, stateRepository);
stateRepository.State.Should().NotBeNull();
var previousState = stateRepository.State!;
var handler2 = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
var (results, rawSink) = await ExecuteFetchAsync(handler2, stateRepository);
results.Should().BeEmpty();
rawSink.Documents.Should().BeEmpty();
stateRepository.State!.DocumentDigests.Should().Equal(previousState.DocumentDigests);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public int CallCount { get; private set; }
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for request.");
}
var responder = _responders.Count > 1
? _responders.Dequeue()
: _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
private static async Task<(List<VexRawDocument> Documents, CapturingRawSink Sink)> ExecuteFetchAsync(
TestHttpMessageHandler handler,
InMemoryConnectorStateRepository stateRepository)
{
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new RedHatConnectorOptions());
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
var rawSink = new CapturingRawSink();
var context = new VexConnectorContext(
null,
VexConnectorSettings.Empty,
rawSink,
new NoopSignatureVerifier(),
new NoopNormalizerRouter(),
new ServiceCollection().BuildServiceProvider(),
ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
return (documents, rawSink);
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (State is not null && string.Equals(State.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult<VexConnectorState?>(State);
}
return ValueTask.FromResult<VexConnectorState?>(null);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,235 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using System.IO.Abstractions.TestingHelpers;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Metadata;
public sealed class RedHatProviderMetadataLoaderTests
{
private const string SampleJson = """
{
"metadata": {
"provider": {
"name": "Red Hat Product Security"
}
},
"distributions": [
{ "directory": "https://access.redhat.com/security/data/csaf/v2/advisories/" }
],
"rolie": {
"feeds": [
{ "url": "https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
]
}
}
""";
[Fact]
public async Task LoadAsync_FetchesMetadataAndCaches()
{
var handler = TestHttpMessageHandler.RespondWith(_ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
return response;
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://access.redhat.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = new RedHatConnectorOptions
{
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
OfflineSnapshotPath = fileSystem.Path.Combine("/offline", "redhat-provider.json"),
CosignIssuer = "https://sigstore.dev/redhat",
CosignIdentityPattern = "^spiffe://redhat/.+$",
};
options.PgpFingerprints.Add("A1B2C3D4E5F6");
options.Validate(fileSystem);
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
Assert.False(result.FromCache);
Assert.False(result.FromOfflineSnapshot);
Assert.Single(result.Provider.BaseUris);
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/", result.Provider.BaseUris[0].ToString());
Assert.Equal("https://access.redhat.com/.well-known/csaf/provider-metadata.json", result.Provider.Discovery.WellKnownMetadata?.ToString());
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom", result.Provider.Discovery.RolIeService?.ToString());
Assert.Equal(1.0, result.Provider.Trust.Weight);
Assert.NotNull(result.Provider.Trust.Cosign);
Assert.Equal("https://sigstore.dev/redhat", result.Provider.Trust.Cosign!.Issuer);
Assert.Equal("^spiffe://redhat/.+$", result.Provider.Trust.Cosign.IdentityPattern);
Assert.Contains("A1B2C3D4E5F6", result.Provider.Trust.PgpFingerprints);
Assert.True(fileSystem.FileExists(options.OfflineSnapshotPath));
Assert.Equal(1, handler.CallCount);
var second = await loader.LoadAsync(CancellationToken.None);
Assert.True(second.FromCache);
Assert.False(second.FromOfflineSnapshot);
Assert.Equal(1, handler.CallCount);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
{
var handler = TestHttpMessageHandler.RespondWith(_ => throw new InvalidOperationException("HTTP should not be called"));
var httpClient = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/snapshots/redhat.json"] = new MockFileData(SampleJson),
});
var options = new RedHatConnectorOptions
{
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
OfflineSnapshotPath = "/snapshots/redhat.json",
PreferOfflineSnapshot = true,
PersistOfflineSnapshot = false,
};
options.Validate(fileSystem);
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
Assert.False(result.FromCache);
Assert.True(result.FromOfflineSnapshot);
Assert.Equal(0, handler.CallCount);
var second = await loader.LoadAsync(CancellationToken.None);
Assert.True(second.FromCache);
Assert.True(second.FromOfflineSnapshot);
Assert.Equal(0, handler.CallCount);
}
[Fact]
public async Task LoadAsync_UsesETagForConditionalRequest()
{
var handler = TestHttpMessageHandler.Create(
_ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
return response;
},
request =>
{
Assert.Contains(request.Headers.IfNoneMatch, etag => etag.Tag == "\"abc\"");
return new HttpResponseMessage(HttpStatusCode.NotModified);
});
var httpClient = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = new RedHatConnectorOptions
{
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
OfflineSnapshotPath = "/offline/redhat.json",
MetadataCacheDuration = TimeSpan.FromMinutes(1),
};
options.Validate(fileSystem);
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
var first = await loader.LoadAsync(CancellationToken.None);
Assert.False(first.FromCache);
Assert.False(first.FromOfflineSnapshot);
Assert.True(cache.TryGetValue(RedHatProviderMetadataLoader.CacheKey, out var entryObj));
Assert.NotNull(entryObj);
var entryType = entryObj!.GetType();
var provider = entryType.GetProperty("Provider")!.GetValue(entryObj);
var fetchedAt = entryType.GetProperty("FetchedAt")!.GetValue(entryObj);
var etag = entryType.GetProperty("ETag")!.GetValue(entryObj);
var fromOffline = entryType.GetProperty("FromOffline")!.GetValue(entryObj);
var expiredEntry = Activator.CreateInstance(entryType, provider, fetchedAt, DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1), etag, fromOffline);
cache.Set(RedHatProviderMetadataLoader.CacheKey, expiredEntry!, new MemoryCacheEntryOptions
{
AbsoluteExpiration = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1),
});
var second = await loader.LoadAsync(CancellationToken.None);
var third = await loader.LoadAsync(CancellationToken.None);
Assert.True(third.FromCache);
Assert.Equal(2, handler.CallCount);
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public int CallCount { get; private set; }
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responder)
=> new(new[] { responder });
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for request.");
}
var responder = _responders.Count > 1
? _responders.Dequeue()
: _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,138 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Authentication;
public sealed class RancherHubTokenProviderTests
{
private const string TokenResponse = "{\"access_token\":\"abc123\",\"token_type\":\"Bearer\",\"expires_in\":3600}";
[Fact]
public async Task GetAccessTokenAsync_RequestsAndCachesToken()
{
var handler = TestHttpMessageHandler.RespondWith(request =>
{
request.Headers.Authorization.Should().NotBeNull();
request.Content.Should().NotBeNull();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenResponse, Encoding.UTF8, "application/json"),
};
});
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions
{
ClientId = "client",
ClientSecret = "secret",
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
Audience = "https://vexhub.suse.com",
};
options.Scopes.Clear();
options.Scopes.Add("hub.read");
options.Scopes.Add("hub.events");
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().NotBeNull();
token!.Value.Should().Be("abc123");
var cached = await provider.GetAccessTokenAsync(options, CancellationToken.None);
cached.Should().NotBeNull();
handler.InvocationCount.Should().Be(1);
}
[Fact]
public async Task GetAccessTokenAsync_ReturnsNullWhenOfflinePreferred()
{
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions
{
PreferOfflineSnapshot = true,
ClientId = "client",
ClientSecret = "secret",
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
};
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().BeNull();
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task GetAccessTokenAsync_ReturnsNullWithoutCredentials()
{
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions();
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().BeNull();
handler.InvocationCount.Should().Be(0);
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public int InvocationCount { get; private set; }
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
=> new(responseFactory);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
return Task.FromResult(_responseFactory(request));
}
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using System.IO.Abstractions.TestingHelpers;
using System.Threading;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Metadata;
public sealed class RancherHubMetadataLoaderTests
{
private const string SampleDiscovery = """
{
"hubId": "excititor:suse.rancher",
"title": "SUSE Rancher VEX Hub",
"subscription": {
"eventsUri": "https://vexhub.suse.com/api/v1/events",
"checkpointUri": "https://vexhub.suse.com/api/v1/checkpoints",
"requiresAuthentication": true,
"channels": ["rke2", "k3s"],
"scopes": ["hub.read", "hub.events"]
},
"authentication": {
"tokenUri": "https://identity.suse.com/oauth2/token",
"audience": "https://vexhub.suse.com"
},
"offline": {
"snapshotUri": "https://downloads.suse.com/vexhub/snapshot.json",
"sha256": "deadbeef",
"updated": "2025-10-10T12:00:00Z"
}
}
""";
[Fact]
public async Task LoadAsync_FetchesAndCachesMetadata()
{
var handler = TestHttpMessageHandler.RespondWith(_ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(SampleDiscovery, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
return response;
});
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = offlinePath,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromCache.Should().BeFalse();
result.FromOfflineSnapshot.Should().BeFalse();
result.Metadata.Provider.DisplayName.Should().Be("SUSE Rancher VEX Hub");
result.Metadata.Subscription.EventsUri.Should().Be(new Uri("https://vexhub.suse.com/api/v1/events"));
result.Metadata.Authentication.TokenEndpoint.Should().Be(new Uri("https://identity.suse.com/oauth2/token"));
// Second call should be served from cache (no additional HTTP invocation).
handler.ResetInvocationCount();
await loader.LoadAsync(options, CancellationToken.None);
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
{
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
fileSystem.AddFile(offlinePath, new MockFileData(SampleDiscovery));
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = offlinePath,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Metadata.Subscription.RequiresAuthentication.Should().BeTrue();
result.Metadata.OfflineSnapshot.Should().NotBeNull();
}
[Fact]
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
{
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = "/offline/missing.json",
PreferOfflineSnapshot = true,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public int InvocationCount { get; private set; }
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
=> new(responseFactory);
public void ResetInvocationCount() => InvocationCount = 0;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
return Task.FromResult(_responseFactory(request));
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,310 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
public sealed class UbuntuCsafConnectorTests
{
[Fact]
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var documentSha = ComputeSha256(documentPayload);
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var stored = sink.Documents.Single();
stored.Digest.Should().Be($"sha256:{documentSha}");
stored.Metadata.TryGetValue("ubuntu.etag", out var storedEtag).Should().BeTrue();
storedEtag.Should().Be("etag-123");
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
handler.DocumentRequestCount.Should().Be(1);
// Second run: Expect connector to send If-None-Match and skip download via 304.
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(2);
handler.SeenIfNoneMatch.Should().Contain("\"etag-123\"");
}
[Fact]
public async Task FetchAsync_SkipsWhenChecksumMismatch()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(new VexConnectorSettings(ImmutableDictionary<string, string>.Empty), CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(1);
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var indexJson = """
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
"sha256": "ignore"
}
]
}
""";
var catalogJson = """
{
"resources": [
{
"id": "{{advisoryId}}",
"type": "csaf",
"url": "{{advisoryUri}}",
"last_modified": "{{timestamp}}",
"hashes": {
"sha256": "{{SHA256}}"
},
"etag": "\"etag-123\"",
"title": "{{advisoryId}}"
}
]
}
""";
return (indexJson, catalogJson);
}
private static string ComputeSha256(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
{
private readonly Uri _indexUri;
private readonly string _indexPayload;
private readonly Uri _catalogUri;
private readonly string _catalogPayload;
private readonly Uri _documentUri;
private readonly byte[] _documentPayload;
private readonly string _expectedEtag;
public int DocumentRequestCount { get; private set; }
public List<string> SeenIfNoneMatch { get; } = new();
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
{
_indexUri = indexUri;
_indexPayload = indexPayload;
_catalogUri = catalogUri;
_catalogPayload = catalogPayload;
_documentUri = documentUri;
_documentPayload = documentPayload;
_expectedEtag = expectedEtag;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == _indexUri)
{
return Task.FromResult(CreateJsonResponse(_indexPayload));
}
if (request.RequestUri == _catalogUri)
{
return Task.FromResult(CreateJsonResponse(_catalogPayload));
}
if (request.RequestUri == _documentUri)
{
DocumentRequestCount++;
if (request.Headers.IfNoneMatch is { Count: > 0 })
{
var header = request.Headers.IfNoneMatch.First().ToString();
SeenIfNoneMatch.Add(header);
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
}
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_documentPayload),
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
private static HttpResponseMessage CreateJsonResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}

View File

@@ -0,0 +1,172 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using System.Threading;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Metadata;
public sealed class UbuntuCatalogLoaderTests
{
private const string SampleIndex = """
{
"generated": "2025-10-10T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "https://ubuntu.com/security/csaf/stable/catalog.json",
"sha256": "abc",
"lastUpdated": "2025-10-09T10:00:00Z"
},
{
"name": "esm",
"catalogUrl": "https://ubuntu.com/security/csaf/esm/catalog.json",
"sha256": "def",
"lastUpdated": "2025-10-08T10:00:00Z"
}
]
}
""";
[Fact]
public async Task LoadAsync_FetchesAndCachesIndex()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
});
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new UbuntuConnectorOptions
{
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.Metadata.Channels.Should().HaveCount(1);
result.Metadata.Channels[0].Name.Should().Be("stable");
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
result.FromCache.Should().BeFalse();
handler.ResetInvocationCount();
var cached = await loader.LoadAsync(options, CancellationToken.None);
cached.FromCache.Should().BeTrue();
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
fileSystem.AddFile("/snapshots/ubuntu-index.json", new MockFileData($"{{\"metadata\":{SampleIndex},\"fetchedAt\":\"2025-10-10T00:00:00Z\"}}"));
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new UbuntuConnectorOptions
{
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
PreferOfflineSnapshot = true,
Channels = { "stable" }
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Metadata.Channels.Should().NotBeEmpty();
}
[Fact]
public async Task LoadAsync_ThrowsWhenNoChannelsMatch()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
});
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new UbuntuConnectorOptions
{
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
};
options.Channels.Clear();
options.Channels.Add("nonexistent");
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
}
private static HttpResponseMessage CreateResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class AdjustableTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
{
_responses = responses;
}
public int InvocationCount { get; private set; }
public void ResetInvocationCount() => InvocationCount = 0;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("unexpected request"),
};
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/StellaOps.Excititor.Connectors.Ubuntu.CSAF.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Aoc;
using StellaOps.Excititor.Core.Aoc;
using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument;
using RawSignatureMetadata = StellaOps.Concelier.RawModels.RawSignatureMetadata;
using RawSourceMetadata = StellaOps.Concelier.RawModels.RawSourceMetadata;
using RawUpstreamMetadata = StellaOps.Concelier.RawModels.RawUpstreamMetadata;
using RawContent = StellaOps.Concelier.RawModels.RawContent;
using RawLinkset = StellaOps.Concelier.RawModels.RawLinkset;
using RawDocumentFactory = StellaOps.Concelier.RawModels.RawDocumentFactory;
using VexStatementSummary = StellaOps.Concelier.RawModels.VexStatementSummary;
using RawReference = StellaOps.Concelier.RawModels.RawReference;
namespace StellaOps.Excititor.Core.Tests.Aoc;
public sealed class VexRawWriteGuardTests
{
private static RawVexDocument CreateDocument(bool signaturePresent = false, bool includeSignaturePayload = true)
{
var signature = signaturePresent
? new RawSignatureMetadata(true, "dsse", "key-1", includeSignaturePayload ? "signed" : null)
: new RawSignatureMetadata(false);
using var contentDoc = JsonDocument.Parse("{\"id\":\"VEX-1\"}");
return RawDocumentFactory.CreateVex(
tenant: "tenant-a",
source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
upstream: new RawUpstreamMetadata(
UpstreamId: "VEX-1",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: signature,
Provenance: ImmutableDictionary<string, string>.Empty),
content: new RawContent("CSA" , "2.0", contentDoc.RootElement.Clone()),
linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
statements: ImmutableArray<VexStatementSummary>.Empty);
}
[Fact]
public void EnsureValid_AllowsMinimalDocument()
{
var guard = new VexRawWriteGuard(new AocWriteGuard());
var document = CreateDocument();
guard.EnsureValid(document);
}
[Fact]
public void EnsureValid_ThrowsWhenSignatureMissingPayload()
{
var guard = new VexRawWriteGuard(new AocWriteGuard());
var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false);
var exception = Assert.Throws<ExcititorAocGuardException>(() => guard.EnsureValid(document));
Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode);
}
}

View File

@@ -0,0 +1,307 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Observations;
public sealed class VexObservationQueryServiceTests
{
private static readonly TimeProvider TimeProvider = TimeProvider.System;
[Fact]
public async Task QueryAsync_WhenNoFilters_ReturnsSortedObservations()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:redhat:0001:1",
tenant: "tenant-a",
providerId: "RedHat",
streamId: "csaf",
vulnerabilityIds: new[] { "CVE-2025-1000" },
productKeys: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
createdAt: now.AddMinutes(-10)),
CreateObservation(
observationId: "tenant-a:ubuntu:0002:1",
tenant: "Tenant-A",
providerId: "ubuntu",
streamId: "cyclonedx",
vulnerabilityIds: new[] { "CVE-2025-1001" },
productKeys: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
purls: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
createdAt: now)
};
var lookup = new InMemoryLookup(observations);
var service = new VexObservationQueryService(lookup);
var result = await service.QueryAsync(new VexObservationQueryOptions("TENANT-A"), CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.Equal("tenant-a:ubuntu:0002:1", result.Observations[0].ObservationId);
Assert.Equal("tenant-a:redhat:0001:1", result.Observations[1].ObservationId);
Assert.Equal(new[] { "CVE-2025-1000", "CVE-2025-1001" }, result.Aggregate.VulnerabilityIds);
Assert.Equal(
new[]
{
"pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1",
"pkg:rpm/redhat/openssl@1.1.1w-12"
},
result.Aggregate.ProductKeys);
Assert.Equal(
new[]
{
"pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1",
"pkg:rpm/redhat/openssl@1.1.1w-12"
},
result.Aggregate.Purls);
Assert.Equal(new[] { "redhat", "ubuntu" }, result.Aggregate.ProviderIds);
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithVulnerabilityAndStatusFilters_FiltersStatements()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:redhat:0001:1",
tenant: "tenant-a",
providerId: "redhat",
streamId: "csaf",
vulnerabilityIds: new[] { "CVE-2025-1000" },
productKeys: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
purls: Array.Empty<string>(),
statuses: new[] { VexClaimStatus.NotAffected },
createdAt: now),
CreateObservation(
observationId: "tenant-a:ubuntu:0002:1",
tenant: "tenant-a",
providerId: "ubuntu",
streamId: "cyclonedx",
vulnerabilityIds: new[] { "CVE-2025-9999" },
productKeys: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
purls: Array.Empty<string>(),
statuses: new[] { VexClaimStatus.Affected },
createdAt: now.AddMinutes(-5))
};
var lookup = new InMemoryLookup(observations);
var service = new VexObservationQueryService(lookup);
var options = new VexObservationQueryOptions(
tenant: "tenant-a",
vulnerabilityIds: new[] { "cve-2025-1000" },
statuses: new[] { VexClaimStatus.NotAffected });
var result = await service.QueryAsync(options, CancellationToken.None);
Assert.Single(result.Observations);
Assert.Equal("tenant-a:redhat:0001:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "CVE-2025-1000" }, result.Aggregate.VulnerabilityIds);
Assert.Equal(new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" }, result.Aggregate.ProductKeys);
}
[Fact]
public async Task QueryAsync_WithCursorAdvancesPages()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:alpha",
tenant: "tenant-a",
providerId: "redhat",
streamId: "csaf",
vulnerabilityIds: new[] { "CVE-2025-0001" },
productKeys: new[] { "pkg:rpm/redhat/foo@1.0.0" },
purls: Array.Empty<string>(),
statuses: new[] { VexClaimStatus.NotAffected },
createdAt: now),
CreateObservation(
observationId: "tenant-a:beta",
tenant: "tenant-a",
providerId: "ubuntu",
streamId: "cyclonedx",
vulnerabilityIds: new[] { "CVE-2025-0002" },
productKeys: new[] { "pkg:deb/ubuntu/foo@1.0.0" },
purls: Array.Empty<string>(),
statuses: new[] { VexClaimStatus.Affected },
createdAt: now.AddMinutes(-1)),
CreateObservation(
observationId: "tenant-a:gamma",
tenant: "tenant-a",
providerId: "suse",
streamId: "openvex",
vulnerabilityIds: new[] { "CVE-2025-0003" },
productKeys: new[] { "pkg:rpm/suse/foo@1.0.0" },
purls: Array.Empty<string>(),
statuses: new[] { VexClaimStatus.UnderInvestigation },
createdAt: now.AddMinutes(-2))
};
var lookup = new InMemoryLookup(observations);
var service = new VexObservationQueryService(lookup);
var first = await service.QueryAsync(
new VexObservationQueryOptions("tenant-a", limit: 2),
CancellationToken.None);
Assert.Equal(2, first.Observations.Length);
Assert.True(first.HasMore);
Assert.NotNull(first.NextCursor);
var second = await service.QueryAsync(
new VexObservationQueryOptions("tenant-a", limit: 2, cursor: first.NextCursor),
CancellationToken.None);
Assert.Single(second.Observations);
Assert.False(second.HasMore);
Assert.Null(second.NextCursor);
Assert.Equal("tenant-a:gamma", second.Observations[0].ObservationId);
}
private static VexObservation CreateObservation(
string observationId,
string tenant,
string providerId,
string streamId,
IEnumerable<string> vulnerabilityIds,
IEnumerable<string> productKeys,
IEnumerable<string> purls,
DateTimeOffset createdAt,
IEnumerable<VexClaimStatus>? statuses = null)
{
var vulnerabilityArray = vulnerabilityIds.ToArray();
var productArray = productKeys.ToArray();
var purlArray = purls.ToArray();
var statusArray = (statuses ?? Array.Empty<VexClaimStatus>()).ToArray();
if (vulnerabilityArray.Length != productArray.Length)
{
throw new ArgumentException("Vulnerability and product collections must align.");
}
var statements = ImmutableArray.CreateBuilder<VexObservationStatement>(vulnerabilityArray.Length);
for (var i = 0; i < vulnerabilityArray.Length; i++)
{
var status = statusArray.Length switch
{
0 => VexClaimStatus.NotAffected,
_ when i < statusArray.Length => statusArray[i],
_ => statusArray[0]
};
var purlValue = purlArray.Length switch
{
0 => null,
_ when i < purlArray.Length => purlArray[i],
_ => purlArray[0]
};
statements.Add(new VexObservationStatement(
vulnerabilityArray[i],
productArray[i],
status,
lastObserved: createdAt,
purl: purlValue,
cpe: null,
evidence: ImmutableArray<JsonNode>.Empty));
}
var upstream = new VexObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{Guid.NewGuid():N}",
signature: new VexObservationSignature(present: false, null, null, null));
var linkset = new VexObservationLinkset(
aliases: vulnerabilityIds,
purls: purls,
cpes: Array.Empty<string>(),
references: new[]
{
new VexObservationReference("source", $"https://example.test/{observationId}")
});
var content = new VexObservationContent(
format: "csaf",
specVersion: "2.0",
raw: JsonNode.Parse("""{"document":"payload"}""") ?? throw new InvalidOperationException("Raw payload required."));
return new VexObservation(
observationId,
tenant,
providerId,
streamId,
upstream,
statements.ToImmutable(),
content,
linkset,
createdAt,
supersedes: ImmutableArray<string>.Empty,
attributes: ImmutableDictionary<string, string>.Empty);
}
private sealed class InMemoryLookup : IVexObservationLookup
{
private readonly IReadOnlyList<VexObservation> _observations;
public InMemoryLookup(IReadOnlyList<VexObservation> observations)
{
_observations = observations;
}
public ValueTask<IReadOnlyList<VexObservation>> ListByTenantAsync(string tenant, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenant);
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(
_observations.Where(observation => string.Equals(observation.Tenant, tenant, StringComparison.OrdinalIgnoreCase)).ToList());
}
public ValueTask<IReadOnlyList<VexObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> vulnerabilityIds,
IReadOnlyCollection<string> productKeys,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
IReadOnlyCollection<string> providerIds,
IReadOnlyCollection<VexClaimStatus> statuses,
VexObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var filtered = _observations
.Where(observation => string.Equals(observation.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.ToList();
if (cursor is not null)
{
filtered = filtered
.Where(observation =>
observation.CreatedAt < cursor.CreatedAt ||
(observation.CreatedAt == cursor.CreatedAt &&
string.CompareOrdinal(observation.ObservationId, cursor.ObservationId) < 0))
.ToList();
}
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(filtered);
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,150 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexCanonicalJsonSerializerTests
{
[Fact]
public void SerializeClaim_ProducesDeterministicOrder()
{
var product = new VexProduct(
key: "pkg:redhat/demo",
name: "Demo App",
version: "1.2.3",
purl: "pkg:rpm/redhat/demo@1.2.3",
cpe: "cpe:2.3:a:redhat:demo:1.2.3",
componentIdentifiers: new[] { "componentB", "componentA" });
var document = new VexClaimDocument(
format: VexDocumentFormat.Csaf,
digest: "sha256:6d5a",
sourceUri: new Uri("https://example.org/vex/csaf.json"),
revision: "2024-09-15",
signature: new VexSignatureMetadata(
type: "pgp",
subject: "CN=Red Hat",
issuer: "CN=Red Hat Root",
keyId: "0xABCD",
verifiedAt: new DateTimeOffset(2025, 10, 14, 9, 30, 0, TimeSpan.Zero)));
var claim = new VexClaim(
vulnerabilityId: "CVE-2025-12345",
providerId: "redhat",
product: product,
status: VexClaimStatus.NotAffected,
document: document,
firstSeen: new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero),
lastSeen: new DateTimeOffset(2025, 10, 11, 12, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent,
detail: "Package not shipped in this channel.",
confidence: new VexConfidence("high", 0.95, "policy/default"),
signals: new VexSignalSnapshot(
new VexSeveritySignal("CVSS:3.1", 7.5, label: "high", vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"),
kev: true,
epss: 0.42),
additionalMetadata: ImmutableDictionary<string, string>.Empty
.Add("source", "csaf")
.Add("revision", "2024-09-15"));
var json = VexCanonicalJsonSerializer.Serialize(claim);
Assert.Equal(
"{\"vulnerabilityId\":\"CVE-2025-12345\",\"providerId\":\"redhat\",\"product\":{\"key\":\"pkg:redhat/demo\",\"name\":\"Demo App\",\"version\":\"1.2.3\",\"purl\":\"pkg:rpm/redhat/demo@1.2.3\",\"cpe\":\"cpe:2.3:a:redhat:demo:1.2.3\",\"componentIdentifiers\":[\"componentA\",\"componentB\"]},\"status\":\"not_affected\",\"justification\":\"component_not_present\",\"detail\":\"Package not shipped in this channel.\",\"signals\":{\"severity\":{\"scheme\":\"CVSS:3.1\",\"score\":7.5,\"label\":\"high\",\"vector\":\"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"},\"kev\":true,\"epss\":0.42},\"document\":{\"format\":\"csaf\",\"digest\":\"sha256:6d5a\",\"sourceUri\":\"https://example.org/vex/csaf.json\",\"revision\":\"2024-09-15\",\"signature\":{\"type\":\"pgp\",\"subject\":\"CN=Red Hat\",\"issuer\":\"CN=Red Hat Root\",\"keyId\":\"0xABCD\",\"verifiedAt\":\"2025-10-14T09:30:00+00:00\",\"transparencyLogReference\":null}},\"firstSeen\":\"2025-10-10T12:00:00+00:00\",\"lastSeen\":\"2025-10-11T12:00:00+00:00\",\"confidence\":{\"level\":\"high\",\"score\":0.95,\"method\":\"policy/default\"},\"additionalMetadata\":{\"revision\":\"2024-09-15\",\"source\":\"csaf\"}}",
json);
}
[Fact]
public void SerializeConsensus_IncludesSignalsInOrder()
{
var product = new VexProduct("pkg:demo/app", "Demo App");
var sources = new[]
{
new VexConsensusSource("redhat", VexClaimStatus.Affected, "sha256:redhat", 1.0),
};
var consensus = new VexConsensus(
"CVE-2025-9999",
product,
VexConsensusStatus.Affected,
new DateTimeOffset(2025, 10, 15, 12, 0, 0, TimeSpan.Zero),
sources,
signals: new VexSignalSnapshot(
new VexSeveritySignal("stellaops:v1", score: 9.1, label: "critical"),
kev: false,
epss: 0.67),
policyVersion: "baseline/v1",
summary: "Affected due to vendor advisory.",
policyRevisionId: "rev-1",
policyDigest: "sha256:abcd");
var json = VexCanonicalJsonSerializer.Serialize(consensus);
Assert.Equal(
"{\"vulnerabilityId\":\"CVE-2025-9999\",\"product\":{\"key\":\"pkg:demo/app\",\"name\":\"Demo App\",\"version\":null,\"purl\":null,\"cpe\":null,\"componentIdentifiers\":[]},\"status\":\"affected\",\"calculatedAt\":\"2025-10-15T12:00:00+00:00\",\"sources\":[{\"providerId\":\"redhat\",\"status\":\"affected\",\"documentDigest\":\"sha256:redhat\",\"weight\":1,\"justification\":null,\"detail\":null,\"confidence\":null}],\"conflicts\":[],\"signals\":{\"severity\":{\"scheme\":\"stellaops:v1\",\"score\":9.1,\"label\":\"critical\",\"vector\":null},\"kev\":false,\"epss\":0.67},\"policyVersion\":\"baseline/v1\",\"summary\":\"Affected due to vendor advisory.\",\"policyDigest\":\"sha256:abcd\",\"policyRevisionId\":\"rev-1\"}",
json);
}
[Fact]
public void QuerySignature_FromFilters_SortsAndNormalizesKeys()
{
var signature = VexQuerySignature.FromFilters(new[]
{
new KeyValuePair<string, string>(" provider ", " redhat "),
new KeyValuePair<string, string>("vulnId", "CVE-2025-12345"),
new KeyValuePair<string, string>("provider", "canonical"),
});
Assert.Equal("provider=canonical&provider=redhat&vulnId=CVE-2025-12345", signature.Value);
}
[Fact]
public void SerializeExportManifest_OrdersArraysAndNestedObjects()
{
var manifest = new VexExportManifest(
exportId: "export/2025/10/15/1",
querySignature: new VexQuerySignature("provider=redhat&format=consensus"),
format: VexExportFormat.OpenVex,
createdAt: new DateTimeOffset(2025, 10, 15, 8, 45, 0, TimeSpan.Zero),
artifact: new VexContentAddress("sha256", "abcd1234"),
claimCount: 42,
sourceProviders: new[] { "cisco", "redhat", "redhat" },
fromCache: true,
consensusRevision: "rev-7",
attestation: new VexAttestationMetadata(
predicateType: "https://in-toto.io/Statement/v0.1",
rekor: new VexRekorReference("v2", "rekor://uuid/1234", "17", new Uri("https://rekor.example/log/17")),
envelopeDigest: "sha256:deadbeef",
signedAt: new DateTimeOffset(2025, 10, 15, 8, 46, 0, TimeSpan.Zero)),
sizeBytes: 4096);
var json = VexCanonicalJsonSerializer.SerializeIndented(manifest);
var providersIndex = json.IndexOf("\"sourceProviders\": [", StringComparison.Ordinal);
Assert.True(providersIndex >= 0);
var ciscoIndex = json.IndexOf("\"cisco\"", providersIndex, StringComparison.Ordinal);
var redhatIndex = json.IndexOf("\"redhat\"", providersIndex, StringComparison.Ordinal);
Assert.True(ciscoIndex >= 0 && redhatIndex > ciscoIndex);
var sequence = new[]
{
"\"consensusRevision\"",
"\"policyRevisionId\"",
"\"policyDigest\"",
"\"consensusDigest\"",
"\"scoreDigest\"",
"\"attestation\""
};
var lastIndex = -1;
foreach (var token in sequence)
{
var index = json.IndexOf(token, StringComparison.Ordinal);
Assert.True(index >= 0, $"Expected to find token {token} in canonical JSON.");
Assert.True(index > lastIndex, $"Token {token} appeared out of order.");
lastIndex = index;
}
}
}

View File

@@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexConsensusResolverTests
{
private static readonly VexProduct DemoProduct = new(
key: "pkg:demo/app",
name: "Demo App",
version: "1.0.0",
purl: "pkg:demo/app@1.0.0",
cpe: "cpe:2.3:a:demo:app:1.0.0");
[Fact]
public void Resolve_SingleAcceptedClaim_SelectsStatus()
{
var provider = CreateProvider("redhat", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0001",
provider.Id,
VexClaimStatus.Affected,
justification: null);
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
Assert.Equal("baseline/v1", result.Consensus.PolicyVersion);
Assert.Single(result.Consensus.Sources);
Assert.Empty(result.Consensus.Conflicts);
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
var decision = Assert.Single(result.DecisionLog);
Assert.True(decision.Included);
Assert.Equal(provider.Id, decision.ProviderId);
Assert.Null(decision.Reason);
}
[Fact]
public void Resolve_NotAffectedWithoutJustification_IsRejected()
{
var provider = CreateProvider("cisco", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0002",
provider.Id,
VexClaimStatus.NotAffected,
justification: null);
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
Assert.Empty(result.Consensus.Sources);
var conflict = Assert.Single(result.Consensus.Conflicts);
Assert.Equal("missing_justification", conflict.Reason);
var decision = Assert.Single(result.DecisionLog);
Assert.False(decision.Included);
Assert.Equal("missing_justification", decision.Reason);
}
[Fact]
public void Resolve_MajorityWeightWins_WithConflictingSources()
{
var vendor = CreateProvider("redhat", VexProviderKind.Vendor);
var distro = CreateProvider("fedora", VexProviderKind.Distro);
var claims = new[]
{
CreateClaim(
"CVE-2025-0003",
vendor.Id,
VexClaimStatus.Affected,
detail: "Vendor advisory",
documentDigest: "sha256:vendor"),
CreateClaim(
"CVE-2025-0003",
distro.Id,
VexClaimStatus.NotAffected,
justification: VexJustification.ComponentNotPresent,
detail: "Distro package not shipped",
documentDigest: "sha256:distro"),
};
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
"CVE-2025-0003",
DemoProduct,
claims,
new Dictionary<string, VexProvider>
{
[vendor.Id] = vendor,
[distro.Id] = distro,
},
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
Assert.Equal(2, result.Consensus.Sources.Length);
Assert.Equal(1.0, result.Consensus.Sources.First(s => s.ProviderId == vendor.Id).Weight);
Assert.Contains(result.Consensus.Conflicts, c => c.ProviderId == distro.Id && c.Reason == "status_conflict");
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
}
[Fact]
public void Resolve_TieFallsBackToUnderInvestigation()
{
var hub = CreateProvider("hub", VexProviderKind.Hub);
var platform = CreateProvider("platform", VexProviderKind.Platform);
var claims = new[]
{
CreateClaim(
"CVE-2025-0004",
hub.Id,
VexClaimStatus.Affected,
detail: "Hub escalation",
documentDigest: "sha256:hub"),
CreateClaim(
"CVE-2025-0004",
platform.Id,
VexClaimStatus.NotAffected,
justification: VexJustification.ProtectedByMitigatingControl,
detail: "Runtime mitigations",
documentDigest: "sha256:platform"),
};
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy(
new VexConsensusPolicyOptions(
hubWeight: 0.5,
platformWeight: 0.5)));
var result = resolver.Resolve(new VexConsensusRequest(
"CVE-2025-0004",
DemoProduct,
claims,
new Dictionary<string, VexProvider>
{
[hub.Id] = hub,
[platform.Id] = platform,
},
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
Assert.Equal(2, result.Consensus.Conflicts.Length);
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("No majority consensus", result.Consensus.Summary!, StringComparison.Ordinal);
}
[Fact]
public void Resolve_RespectsRaisedWeightCeiling()
{
var provider = CreateProvider("vendor", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0100",
provider.Id,
VexClaimStatus.Affected,
documentDigest: "sha256:vendor");
var policy = new BaselineVexConsensusPolicy(new VexConsensusPolicyOptions(
vendorWeight: 1.4,
weightCeiling: 2.0));
var resolver = new VexConsensusResolver(policy);
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z"),
WeightCeiling: 2.0));
var source = Assert.Single(result.Consensus.Sources);
Assert.Equal(1.4, source.Weight);
}
private static VexProvider CreateProvider(string id, VexProviderKind kind)
=> new(
id,
displayName: id.ToUpperInvariant(),
kind,
baseUris: Array.Empty<Uri>(),
trust: new VexProviderTrust(weight: 1.0, cosign: null));
private static VexClaim CreateClaim(
string vulnerabilityId,
string providerId,
VexClaimStatus status,
VexJustification? justification = null,
string? detail = null,
string? documentDigest = null)
=> new(
vulnerabilityId,
providerId,
DemoProduct,
status,
new VexClaimDocument(
VexDocumentFormat.Csaf,
documentDigest ?? $"sha256:{providerId}",
new Uri($"https://example.org/{providerId}/{vulnerabilityId}.json"),
"1"),
firstSeen: DateTimeOffset.Parse("2025-10-10T12:00:00Z"),
lastSeen: DateTimeOffset.Parse("2025-10-11T12:00:00Z"),
justification,
detail,
confidence: null,
additionalMetadata: ImmutableDictionary<string, string>.Empty);
}

View File

@@ -0,0 +1,130 @@
using System;
using System.IO;
using System.Text;
using StellaOps.Excititor.Policy;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexPolicyBinderTests
{
private const string JsonPolicy = """
{
"version": "custom/v2",
"weights": {
"vendor": 1.3,
"distro": 0.85,
"ceiling": 2.0
},
"scoring": {
"alpha": 0.35,
"beta": 0.75
},
"providerOverrides": {
"provider.example": 1.8
}
}
""";
private const string YamlPolicy = """
version: custom/v3
weights:
vendor: 0.8
distro: 0.7
platform: 0.6
providerOverrides:
provider-a: 0.4
provider-b: 0.3
""";
[Fact]
public void Bind_Json_ReturnsNormalizedOptions()
{
var result = VexPolicyBinder.Bind(JsonPolicy, VexPolicyDocumentFormat.Json);
Assert.True(result.Success);
Assert.NotNull(result.Options);
Assert.NotNull(result.NormalizedOptions);
Assert.Equal("custom/v2", result.Options!.Version);
Assert.Equal("custom/v2", result.NormalizedOptions!.Version);
Assert.Equal(1.3, result.NormalizedOptions.VendorWeight);
Assert.Equal(0.85, result.NormalizedOptions.DistroWeight);
Assert.Equal(2.0, result.NormalizedOptions.WeightCeiling);
Assert.Equal(0.35, result.NormalizedOptions.Alpha);
Assert.Equal(0.75, result.NormalizedOptions.Beta);
Assert.Equal(1.8, result.NormalizedOptions.ProviderOverrides["provider.example"]);
Assert.Empty(result.Issues);
}
[Fact]
public void Bind_Yaml_ReturnsOverridesAndWarningsSorted()
{
var result = VexPolicyBinder.Bind(YamlPolicy, VexPolicyDocumentFormat.Yaml);
Assert.True(result.Success);
Assert.NotNull(result.NormalizedOptions);
var overrides = result.NormalizedOptions!.ProviderOverrides;
Assert.Equal(2, overrides.Count);
Assert.Equal(0.4, overrides["provider-a"]);
Assert.Equal(0.3, overrides["provider-b"]);
Assert.Empty(result.Issues);
}
[Fact]
public void Bind_InvalidJson_ReturnsError()
{
const string invalidJson = "{ \"weights\": { \"vendor\": \"not-a-number\" }";
var result = VexPolicyBinder.Bind(invalidJson, VexPolicyDocumentFormat.Json);
Assert.False(result.Success);
var issue = Assert.Single(result.Issues);
Assert.Equal(VexPolicyIssueSeverity.Error, issue.Severity);
Assert.StartsWith("policy.parse.json", issue.Code, StringComparison.Ordinal);
}
[Fact]
public void Bind_Stream_SupportsEncoding()
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonPolicy));
var result = VexPolicyBinder.Bind(stream, VexPolicyDocumentFormat.Json);
Assert.True(result.Success);
Assert.NotNull(result.Options);
}
[Fact]
public void Bind_InvalidWeightsAndScoring_EmitsWarningsAndClamps()
{
const string policy = """
{
"weights": {
"vendor": 3.5,
"ceiling": 0.8
},
"scoring": {
"alpha": -0.1,
"beta": 10.0
},
"providerOverrides": {
"bad": 4.0
}
}
""";
var result = VexPolicyBinder.Bind(policy, VexPolicyDocumentFormat.Json);
Assert.True(result.Success);
Assert.NotNull(result.NormalizedOptions);
var consensus = result.NormalizedOptions!;
Assert.Equal(1.0, consensus.WeightCeiling);
Assert.Equal(1.0, consensus.VendorWeight);
Assert.Equal(1.0, consensus.ProviderOverrides["bad"]);
Assert.Equal(VexConsensusPolicyOptions.DefaultAlpha, consensus.Alpha);
Assert.Equal(VexConsensusPolicyOptions.MaxSupportedCoefficient, consensus.Beta);
Assert.Contains(result.Issues, issue => issue.Code == "weights.ceiling.minimum");
Assert.Contains(result.Issues, issue => issue.Code == "weights.vendor.range");
Assert.Contains(result.Issues, issue => issue.Code == "weights.overrides.bad.range");
Assert.Contains(result.Issues, issue => issue.Code == "scoring.alpha.range");
Assert.Contains(result.Issues, issue => issue.Code == "scoring.beta.maximum");
}
}

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.Core.Tests;
public class VexPolicyDiagnosticsTests
{
[Fact]
public void GetDiagnostics_ReportsCountsRecommendationsAndOverrides()
{
var overrides = new[]
{
new KeyValuePair<string, double>("provider-a", 0.8),
new KeyValuePair<string, double>("provider-b", 0.6),
};
var snapshot = new VexPolicySnapshot(
"custom/v1",
new VexConsensusPolicyOptions(
version: "custom/v1",
providerOverrides: overrides),
new BaselineVexConsensusPolicy(),
ImmutableArray.Create(
new VexPolicyIssue("sample.error", "Blocking issue.", VexPolicyIssueSeverity.Error),
new VexPolicyIssue("sample.warning", "Non-blocking issue.", VexPolicyIssueSeverity.Warning)),
"rev-test",
"ABCDEF");
var fakeProvider = new FakePolicyProvider(snapshot);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal("custom/v1", report.Version);
Assert.Equal("rev-test", report.RevisionId);
Assert.Equal("ABCDEF", report.Digest);
Assert.Equal(1, report.ErrorCount);
Assert.Equal(1, report.WarningCount);
Assert.Equal(fakeTime.GetUtcNow(), report.GeneratedAt);
Assert.Collection(report.Issues,
issue => Assert.Equal("sample.error", issue.Code),
issue => Assert.Equal("sample.warning", issue.Code));
Assert.Equal(new[] { "provider-a", "provider-b" }, report.ActiveOverrides.Keys.OrderBy(static key => key, StringComparer.Ordinal));
Assert.Contains(report.Recommendations, message => message.Contains("Resolve policy errors", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("provider-a", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("docs/ARCHITECTURE_EXCITITOR.md", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetDiagnostics_WhenNoIssues_StillReturnsDefaultRecommendation()
{
var fakeProvider = new FakePolicyProvider(VexPolicySnapshot.Default);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal(0, report.ErrorCount);
Assert.Equal(0, report.WarningCount);
Assert.Empty(report.ActiveOverrides);
Assert.Single(report.Recommendations);
}
[Fact]
public void PolicyProvider_ComputesRevisionAndDigest_AndEmitsTelemetry()
{
using var listener = new MeterListener();
var reloadMeasurements = 0;
string? lastRevision = null;
listener.InstrumentPublished += (instrument, _) =>
{
if (instrument.Meter.Name == "StellaOps.Excititor.Policy" &&
instrument.Name == "vex.policy.reloads")
{
listener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
reloadMeasurements++;
foreach (var tag in tags)
{
if (tag.Key is "revision" && tag.Value is string revision)
{
lastRevision = revision;
break;
}
}
});
listener.Start();
var optionsMonitor = new MutableOptionsMonitor<VexPolicyOptions>(new VexPolicyOptions());
var provider = new VexPolicyProvider(optionsMonitor, NullLogger<VexPolicyProvider>.Instance);
var snapshot1 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot1.RevisionId);
Assert.False(string.IsNullOrWhiteSpace(snapshot1.Digest));
var snapshot2 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot2.RevisionId);
Assert.Equal(snapshot1.Digest, snapshot2.Digest);
optionsMonitor.Update(new VexPolicyOptions
{
ProviderOverrides = new Dictionary<string, double>
{
["provider-a"] = 0.4
}
});
var snapshot3 = provider.GetSnapshot();
Assert.Equal("rev-2", snapshot3.RevisionId);
Assert.NotEqual(snapshot1.Digest, snapshot3.Digest);
listener.Dispose();
Assert.True(reloadMeasurements >= 2);
Assert.Equal("rev-2", lastRevision);
}
private sealed class FakePolicyProvider : IVexPolicyProvider
{
private readonly VexPolicySnapshot _snapshot;
public FakePolicyProvider(VexPolicySnapshot snapshot)
{
_snapshot = snapshot;
}
public VexPolicySnapshot GetSnapshot() => _snapshot;
}
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _value;
public MutableOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public void Update(T newValue) => _value = newValue;
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Generic;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexQuerySignatureTests
{
[Fact]
public void FromFilters_SortsAlphabetically()
{
var filters = new[]
{
new KeyValuePair<string, string>("provider", "redhat"),
new KeyValuePair<string, string>("vulnId", "CVE-2025-0001"),
new KeyValuePair<string, string>("provider", "cisco"),
};
var signature = VexQuerySignature.FromFilters(filters);
Assert.Equal("provider=cisco&provider=redhat&vulnId=CVE-2025-0001", signature.Value);
}
[Fact]
public void FromQuery_NormalizesFiltersAndSort()
{
var query = VexQuery.Create(
filters: new[]
{
new VexQueryFilter(" provider ", " redhat "),
new VexQueryFilter("vulnId", "CVE-2025-0002"),
},
sort: new[]
{
new VexQuerySort("published", true),
new VexQuerySort("severity", false),
},
limit: 200,
offset: 10,
view: "consensus");
var signature = VexQuerySignature.FromQuery(query);
Assert.Equal(
"provider=redhat&vulnId=CVE-2025-0002&sort=-published&sort=+severity&limit=200&offset=10&view=consensus",
signature.Value);
}
[Fact]
public void ComputeHash_ReturnsStableSha256()
{
var signature = new VexQuerySignature("provider=redhat&vulnId=CVE-2025-0003");
var address = signature.ComputeHash();
Assert.Equal("sha256", address.Algorithm);
Assert.Equal("44c9881aaa79050ae943eaaf78afa697b1a4d3e38b03e20db332f2bd1e5b1029", address.Digest);
}
}

View File

@@ -0,0 +1,35 @@
using System;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexSignalSnapshotTests
{
[Theory]
[InlineData(-0.01)]
[InlineData(1.01)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void Constructor_InvalidEpss_Throws(double value)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new VexSignalSnapshot(epss: value));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void VexSeveritySignal_InvalidScheme_Throws(string? scheme)
{
Assert.Throws<ArgumentException>(() => new VexSeveritySignal(scheme!));
}
[Theory]
[InlineData(-0.1)]
[InlineData(double.NaN)]
[InlineData(double.NegativeInfinity)]
public void VexSeveritySignal_InvalidScore_Throws(double value)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new VexSeveritySignal("cvss", value));
}
}

View File

@@ -0,0 +1,406 @@
using System;
using System.Collections.Immutable;
using System.IO;
using System.Text;
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
namespace StellaOps.Excititor.Export.Tests;
public sealed class ExportEngineTests
{
[Fact]
public async Task ExportAsync_GeneratesAndCachesManifest()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new InMemoryExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow, ForceRefresh: false);
var manifest = await engine.ExportAsync(context, CancellationToken.None);
Assert.False(manifest.FromCache);
Assert.Equal(VexExportFormat.Json, manifest.Format);
Assert.Equal("baseline/v1", manifest.ConsensusRevision);
Assert.Equal(1, manifest.ClaimCount);
Assert.NotNull(dataSource.LastDataSet);
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
dataSource.LastDataSet!,
VexPolicySnapshot.Default,
context.RequestedAt);
Assert.NotNull(manifest.ConsensusDigest);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Algorithm, manifest.ConsensusDigest!.Algorithm);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest.Digest);
Assert.NotNull(manifest.ScoreDigest);
Assert.Equal(expectedEnvelopes.ScoreDigest.Algorithm, manifest.ScoreDigest!.Algorithm);
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest.Digest);
Assert.Empty(manifest.QuietProvenance);
// second call hits cache
var cached = await engine.ExportAsync(context, CancellationToken.None);
Assert.True(cached.FromCache);
Assert.Equal(manifest.ExportId, cached.ExportId);
}
[Fact]
public async Task ExportAsync_ForceRefreshInvalidatesCacheEntry()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new InMemoryExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var cacheIndex = new RecordingCacheIndex();
var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance, cacheIndex);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var initialContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
_ = await engine.ExportAsync(initialContext, CancellationToken.None);
var refreshContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow.AddMinutes(1), ForceRefresh: true);
var refreshed = await engine.ExportAsync(refreshContext, CancellationToken.None);
Assert.False(refreshed.FromCache);
var signature = VexQuerySignature.FromQuery(refreshContext.Query);
Assert.True(cacheIndex.RemoveCalls.TryGetValue((signature.Value, refreshContext.Format), out var removed));
Assert.True(removed);
}
[Fact]
public async Task ExportAsync_WritesArtifactsToAllStores()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new InMemoryExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var recorder1 = new RecordingArtifactStore();
var recorder2 = new RecordingArtifactStore();
var engine = new VexExportEngine(
store,
evaluator,
dataSource,
new[] { exporter },
NullLogger<VexExportEngine>.Instance,
cacheIndex: null,
artifactStores: new[] { recorder1, recorder2 });
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
await engine.ExportAsync(context, CancellationToken.None);
Assert.Equal(1, recorder1.SaveCount);
Assert.Equal(1, recorder2.SaveCount);
}
[Fact]
public async Task ExportAsync_AttachesAttestationMetadata()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new InMemoryExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var attestation = new RecordingAttestationClient();
var engine = new VexExportEngine(
store,
evaluator,
dataSource,
new[] { exporter },
NullLogger<VexExportEngine>.Instance,
cacheIndex: null,
artifactStores: null,
attestationClient: attestation);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var requestedAt = DateTimeOffset.UtcNow;
var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt);
var manifest = await engine.ExportAsync(context, CancellationToken.None);
Assert.NotNull(attestation.LastRequest);
Assert.NotNull(dataSource.LastDataSet);
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
dataSource.LastDataSet!,
VexPolicySnapshot.Default,
requestedAt);
Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId);
var metadata = attestation.LastRequest.Metadata;
Assert.True(metadata.ContainsKey("consensusDigest"), "Consensus digest metadata missing");
Assert.Equal(expectedEnvelopes.ConsensusDigest.ToUri(), metadata["consensusDigest"]);
Assert.True(metadata.ContainsKey("scoreDigest"), "Score digest metadata missing");
Assert.Equal(expectedEnvelopes.ScoreDigest.ToUri(), metadata["scoreDigest"]);
Assert.Equal(expectedEnvelopes.Consensus.Length.ToString(CultureInfo.InvariantCulture), metadata["consensusEntryCount"]);
Assert.Equal(expectedEnvelopes.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture), metadata["scoreEntryCount"]);
Assert.Equal(VexPolicySnapshot.Default.RevisionId, metadata["policyRevisionId"]);
Assert.Equal(VexPolicySnapshot.Default.Version, metadata["policyVersion"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreAlpha"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreBeta"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreWeightCeiling"]);
Assert.NotNull(manifest.Attestation);
Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest);
Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType);
Assert.NotNull(manifest.ConsensusDigest);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest!.Digest);
Assert.NotNull(manifest.ScoreDigest);
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest!.Digest);
Assert.Empty(manifest.QuietProvenance);
Assert.NotNull(store.LastSavedManifest);
Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation);
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
}
[Fact]
public async Task ExportAsync_IncludesQuietProvenanceMetadata()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new QuietExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var attestation = new RecordingAttestationClient();
var engine = new VexExportEngine(
store,
evaluator,
dataSource,
new[] { exporter },
NullLogger<VexExportEngine>.Instance,
cacheIndex: null,
artifactStores: null,
attestationClient: attestation);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0002") });
var requestedAt = DateTimeOffset.UtcNow;
var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt);
var manifest = await engine.ExportAsync(context, CancellationToken.None);
var quiet = Assert.Single(manifest.QuietProvenance);
Assert.Equal("CVE-2025-0002", quiet.VulnerabilityId);
Assert.Equal("pkg:demo/app", quiet.ProductKey);
var statement = Assert.Single(quiet.Statements);
Assert.Equal("vendor", statement.ProviderId);
Assert.Equal("sha256:quiet", statement.StatementId);
Assert.Equal(VexJustification.ComponentNotPresent, statement.Justification);
Assert.NotNull(statement.Signature);
Assert.Equal("quiet-signer", statement.Signature!.Subject);
Assert.Equal("quiet-key", statement.Signature.KeyId);
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(manifest.QuietProvenance);
Assert.NotNull(attestation.LastRequest);
Assert.True(attestation.LastRequest!.Metadata.TryGetValue("quietedBy", out var quietJson));
Assert.Equal(expectedQuietJson, quietJson);
Assert.True(attestation.LastRequest.Metadata.TryGetValue("quietedByStatementCount", out var quietCount));
Assert.Equal("1", quietCount);
Assert.NotNull(store.LastSavedManifest);
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
}
private sealed class InMemoryExportStore : IVexExportStore
{
private readonly Dictionary<string, VexExportManifest> _store = new(StringComparer.Ordinal);
public VexExportManifest? LastSavedManifest { get; private set; }
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var key = CreateKey(signature.Value, format);
_store.TryGetValue(key, out var manifest);
return ValueTask.FromResult<VexExportManifest?>(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var key = CreateKey(manifest.QuerySignature.Value, manifest.Format);
_store[key] = manifest;
LastSavedManifest = manifest;
return ValueTask.CompletedTask;
}
private static string CreateKey(string signature, VexExportFormat format)
=> FormattableString.Invariant($"{signature}|{format}");
}
private sealed class QuietExportDataSource : IVexExportDataSource
{
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
var signature = new VexSignatureMetadata(
type: "pgp",
subject: "quiet-signer",
issuer: "quiet-ca",
keyId: "quiet-key",
verifiedAt: DateTimeOffset.UnixEpoch,
transparencyLogReference: "rekor://quiet");
var claim = new VexClaim(
"CVE-2025-0002",
"vendor",
new VexProduct("pkg:demo/app", "Demo"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/quiet"), signature: signature),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent);
var consensus = new VexConsensus(
"CVE-2025-0002",
claim.Product,
VexConsensusStatus.NotAffected,
DateTimeOffset.UtcNow,
new[]
{
new VexConsensusSource("vendor", VexClaimStatus.NotAffected, "sha256:quiet", 1.0, claim.Justification),
},
conflicts: null,
policyVersion: "baseline/v1",
summary: "not_affected");
return ValueTask.FromResult(new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor")));
}
}
private sealed class RecordingAttestationClient : IVexAttestationClient
{
public VexAttestationRequest? LastRequest { get; private set; }
public VexAttestationResponse Response { get; } = new VexAttestationResponse(
new VexAttestationMetadata(
predicateType: "https://stella-ops.org/attestations/vex-export",
rekor: new VexRekorReference("0.2", "rekor://entry", "123"),
envelopeDigest: "sha256:envelope",
signedAt: DateTimeOffset.UnixEpoch),
ImmutableDictionary<string, string>.Empty);
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
{
LastRequest = request;
return ValueTask.FromResult(Response);
}
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
}
private sealed class RecordingCacheIndex : IVexCacheIndex
{
public Dictionary<(string Signature, VexExportFormat Format), bool> RemoveCalls { get; } = new();
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<VexCacheEntry?>(null);
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
RemoveCalls[(signature.Value, format)] = true;
return ValueTask.CompletedTask;
}
}
private sealed class RecordingArtifactStore : IVexArtifactStore
{
public int SaveCount { get; private set; }
public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
SaveCount++;
return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory", artifact.Content.Length, artifact.Metadata));
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
=> ValueTask.FromResult<Stream?>(null);
}
private sealed class StaticPolicyEvaluator : IVexPolicyEvaluator
{
public StaticPolicyEvaluator(string version)
{
Version = version;
}
public string Version { get; }
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
public double GetProviderWeight(VexProvider provider) => 1.0;
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
rejectionReason = null;
return true;
}
}
private sealed class InMemoryExportDataSource : IVexExportDataSource
{
public VexExportDataSet? LastDataSet { get; private set; }
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
var claim = new VexClaim(
"CVE-2025-0001",
"vendor",
new VexProduct("pkg:demo/app", "Demo"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:demo", new Uri("https://example.org/demo")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow);
var consensus = new VexConsensus(
"CVE-2025-0001",
claim.Product,
VexConsensusStatus.Affected,
DateTimeOffset.UtcNow,
new[] { new VexConsensusSource("vendor", VexClaimStatus.Affected, "sha256:demo", 1.0) },
conflicts: null,
policyVersion: "baseline/v1",
summary: "affected");
var dataSet = new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor"));
LastDataSet = dataSet;
return ValueTask.FromResult(dataSet);
}
}
private sealed class DummyExporter : IVexExporter
{
public DummyExporter(VexExportFormat format)
{
Format = format;
}
public VexExportFormat Format { get; }
public VexContentAddress Digest(VexExportRequest request)
=> new("sha256", "deadbeef");
public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken)
{
var bytes = System.Text.Encoding.UTF8.GetBytes("{}");
output.Write(bytes);
return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty));
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using System.IO.Abstractions.TestingHelpers;
namespace StellaOps.Excititor.Export.Tests;
public sealed class FileSystemArtifactStoreTests
{
[Fact]
public async Task SaveAsync_WritesArtifactToDisk()
{
var fs = new MockFileSystem();
var options = Options.Create(new FileSystemArtifactStoreOptions { RootPath = "/exports" });
var store = new FileSystemArtifactStore(options, NullLogger<FileSystemArtifactStore>.Instance, fs);
var content = new byte[] { 1, 2, 3 };
var artifact = new VexExportArtifact(
new VexContentAddress("sha256", "deadbeef"),
VexExportFormat.Json,
content,
ImmutableDictionary<string, string>.Empty);
var stored = await store.SaveAsync(artifact, CancellationToken.None);
Assert.Equal(artifact.Content.Length, stored.SizeBytes);
var filePath = fs.Path.Combine(options.Value.RootPath, stored.Location);
Assert.True(fs.FileExists(filePath));
Assert.Equal(content, fs.File.ReadAllBytes(filePath));
}
}

View File

@@ -0,0 +1,324 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Export.Tests;
public sealed class MirrorBundlePublisherTests
{
[Fact]
public async Task PublishAsync_WritesMirrorArtifacts()
{
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(generatedAt);
var fileSystem = new MockFileSystem();
var options = new MirrorDistributionOptions
{
OutputRoot = @"C:\exports",
DirectoryName = "mirror",
TargetRepository = "s3://mirror/excititor",
};
var domain = new MirrorDomainOptions
{
Id = "primary",
DisplayName = "Primary Mirror",
};
var exportOptions = new MirrorExportOptions
{
Key = "consensus-json",
Format = "json",
};
exportOptions.Filters["vulnId"] = "CVE-2025-0001";
domain.Exports.Add(exportOptions);
options.Domains.Add(domain);
var publisher = new VexMirrorBundlePublisher(
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
NullLogger<VexMirrorBundlePublisher>.Instance,
timeProvider,
fileSystem,
cryptoRegistry: null,
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
var sample = CreateSampleExport(generatedAt);
var manifest = sample.Manifest;
var envelope = sample.Envelope;
var dataSet = sample.DataSet;
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
var mirrorRoot = @"C:\exports\mirror";
var domainRoot = Path.Combine(mirrorRoot, "primary");
var bundlePath = Path.Combine(domainRoot, "bundle.json");
var manifestPath = Path.Combine(domainRoot, "manifest.json");
var indexPath = Path.Combine(mirrorRoot, "index.json");
var signaturePath = Path.Combine(domainRoot, "bundle.json.jws");
Assert.True(fileSystem.File.Exists(bundlePath));
Assert.True(fileSystem.File.Exists(manifestPath));
Assert.True(fileSystem.File.Exists(indexPath));
Assert.False(fileSystem.File.Exists(signaturePath));
var bundleBytes = fileSystem.File.ReadAllBytes(bundlePath);
var manifestBytes = fileSystem.File.ReadAllBytes(manifestPath);
var indexBytes = fileSystem.File.ReadAllBytes(indexPath);
var expectedBundleDigest = ComputeSha256(bundleBytes);
var expectedManifestDigest = ComputeSha256(manifestBytes);
var expectedConsensusJson = envelope.ConsensusCanonicalJson;
var expectedScoreJson = envelope.ScoreCanonicalJson;
var expectedClaimsJson = SerializeClaims(dataSet.Claims);
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(envelope.QuietProvenance);
using (var bundleDocument = JsonDocument.Parse(bundleBytes))
{
var root = bundleDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("primary", root.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
Assert.Single(exports);
var export = exports[0];
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
Assert.Equal("json", export.GetProperty("format").GetString());
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
Assert.Equal(expectedConsensusJson, export.GetProperty("consensusDocument").GetString());
Assert.Equal(expectedScoreJson, export.GetProperty("scoreDocument").GetString());
Assert.Equal(expectedClaimsJson, export.GetProperty("claimsDocument").GetString());
Assert.Equal(expectedQuietJson, export.GetProperty("quietDocument").GetString());
var providers = export.GetProperty("sourceProviders").EnumerateArray().Select(p => p.GetString()).ToArray();
Assert.Single(providers);
Assert.Equal("vendor", providers[0]);
}
using (var manifestDocument = JsonDocument.Parse(manifestBytes))
{
var root = manifestDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("primary", root.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var bundleDescriptor = root.GetProperty("bundle");
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
Assert.False(bundleDescriptor.TryGetProperty("signature", out _));
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
Assert.Single(exports);
var export = exports[0];
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
Assert.Equal("json", export.GetProperty("format").GetString());
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
Assert.False(export.TryGetProperty("attestation", out _));
}
using (var indexDocument = JsonDocument.Parse(indexBytes))
{
var root = indexDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var domains = root.GetProperty("domains").EnumerateArray().ToArray();
Assert.Single(domains);
var entry = domains[0];
Assert.Equal("primary", entry.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", entry.GetProperty("displayName").GetString());
Assert.Equal(generatedAt, entry.GetProperty("generatedAt").GetDateTimeOffset());
Assert.Equal(1, entry.GetProperty("exportCount").GetInt32());
var manifestDescriptor = entry.GetProperty("manifest");
Assert.Equal("primary/manifest.json", manifestDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedManifestDigest, manifestDescriptor.GetProperty("digest").GetString());
Assert.Equal(manifestBytes.LongLength, manifestDescriptor.GetProperty("sizeBytes").GetInt64());
var bundleDescriptor = entry.GetProperty("bundle");
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
var exportKeys = entry.GetProperty("exportKeys").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Single(exportKeys);
Assert.Equal("consensus-json", exportKeys[0]);
}
}
[Fact]
public async Task PublishAsync_NoMatchingDomain_DoesNotWriteArtifacts()
{
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(generatedAt);
var fileSystem = new MockFileSystem();
var options = new MirrorDistributionOptions
{
OutputRoot = @"C:\exports",
DirectoryName = "mirror",
};
var domain = new MirrorDomainOptions
{
Id = "primary",
DisplayName = "Primary Mirror",
};
var exportOptions = new MirrorExportOptions
{
Key = "consensus-json",
Format = "json",
};
exportOptions.Filters["vulnId"] = "CVE-2099-9999";
domain.Exports.Add(exportOptions);
options.Domains.Add(domain);
var publisher = new VexMirrorBundlePublisher(
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
NullLogger<VexMirrorBundlePublisher>.Instance,
timeProvider,
fileSystem,
cryptoRegistry: null,
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
var sample = CreateSampleExport(generatedAt);
await publisher.PublishAsync(sample.Manifest, sample.Envelope, sample.DataSet, CancellationToken.None);
Assert.False(fileSystem.Directory.Exists(@"C:\exports\mirror"));
}
private static SampleExport CreateSampleExport(DateTimeOffset generatedAt)
{
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var signature = VexQuerySignature.FromQuery(query);
var product = new VexProduct("pkg:demo/app", "Demo");
var document = new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/vex.json"));
var claim = new VexClaim(
"CVE-2025-0001",
"vendor",
product,
VexClaimStatus.NotAffected,
document,
generatedAt.AddDays(-1),
generatedAt,
justification: VexJustification.ComponentNotPresent);
var consensus = new VexConsensus(
"CVE-2025-0001",
product,
VexConsensusStatus.NotAffected,
generatedAt,
new[] { new VexConsensusSource("vendor", VexClaimStatus.NotAffected, document.Digest, 1.0, claim.Justification) },
conflicts: null,
signals: null,
policyVersion: "baseline/v1",
summary: "not_affected",
policyRevisionId: "policy/v1",
policyDigest: "sha256:policy");
var dataSet = new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor"));
var envelope = VexExportEnvelopeBuilder.Build(dataSet, VexPolicySnapshot.Default, generatedAt);
var manifest = new VexExportManifest(
"exports/20251021T120000000Z/abcdef",
signature,
VexExportFormat.Json,
generatedAt,
new VexContentAddress("sha256", "deadbeef"),
dataSet.Claims.Length,
dataSet.SourceProviders,
consensusRevision: "baseline/v1",
policyRevisionId: "policy/v1",
policyDigest: "sha256:policy",
consensusDigest: envelope.ConsensusDigest,
scoreDigest: envelope.ScoreDigest,
quietProvenance: envelope.QuietProvenance,
attestation: null,
sizeBytes: 1024);
return new SampleExport(manifest, envelope, dataSet);
}
private static string SerializeClaims(ImmutableArray<VexClaim> claims)
=> VexCanonicalJsonSerializer.Serialize(
claims
.OrderBy(claim => claim.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(claim => claim.Product.Key, StringComparer.Ordinal)
.ThenBy(claim => claim.ProviderId, StringComparer.Ordinal)
.ToImmutableArray());
private static string ComputeSha256(byte[] bytes)
{
using var sha = SHA256.Create();
var digest = sha.ComputeHash(bytes);
return "sha256:" + Convert.ToHexString(digest).ToLowerInvariant();
}
private sealed record SampleExport(
VexExportManifest Manifest,
VexExportEnvelopeContext Envelope,
VexExportDataSet DataSet);
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _value;
public FixedTimeProvider(DateTimeOffset value) => _value = value;
public override DateTimeOffset GetUtcNow() => _value;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
public StaticOptionsMonitor(T value) => CurrentValue = value;
public T CurrentValue { get; private set; }
public T Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.Export.Tests;
public sealed class OfflineBundleArtifactStoreTests
{
[Fact]
public async Task SaveAsync_WritesArtifactAndManifest()
{
var fs = new MockFileSystem();
var options = Options.Create(new OfflineBundleArtifactStoreOptions { RootPath = "/offline" });
var store = new OfflineBundleArtifactStore(options, NullLogger<OfflineBundleArtifactStore>.Instance, fs);
var content = new byte[] { 1, 2, 3 };
var digest = "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content)).ToLowerInvariant();
var artifact = new VexExportArtifact(
new VexContentAddress("sha256", digest.Split(':')[1]),
VexExportFormat.Json,
content,
ImmutableDictionary<string, string>.Empty);
var stored = await store.SaveAsync(artifact, CancellationToken.None);
var artifactPath = fs.Path.Combine(options.Value.RootPath, stored.Location);
Assert.True(fs.FileExists(artifactPath));
var manifestPath = fs.Path.Combine(options.Value.RootPath, options.Value.ManifestFileName);
Assert.True(fs.FileExists(manifestPath));
await using var manifestStream = fs.File.OpenRead(manifestPath);
using var document = await JsonDocument.ParseAsync(manifestStream);
var artifacts = document.RootElement.GetProperty("artifacts");
Assert.True(artifacts.GetArrayLength() >= 1);
var first = artifacts.EnumerateArray().First();
Assert.Equal(digest, first.GetProperty("digest").GetString());
}
[Fact]
public async Task SaveAsync_ThrowsOnDigestMismatch()
{
var fs = new MockFileSystem();
var options = Options.Create(new OfflineBundleArtifactStoreOptions { RootPath = "/offline" });
var store = new OfflineBundleArtifactStore(options, NullLogger<OfflineBundleArtifactStore>.Instance, fs);
var artifact = new VexExportArtifact(
new VexContentAddress("sha256", "deadbeef"),
VexExportFormat.Json,
new byte[] { 0x01, 0x02 },
ImmutableDictionary<string, string>.Empty);
await Assert.ThrowsAsync<InvalidOperationException>(() => store.SaveAsync(artifact, CancellationToken.None).AsTask());
}
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.Export.Tests;
public sealed class S3ArtifactStoreTests
{
[Fact]
public async Task SaveAsync_UploadsContentWithMetadata()
{
var client = new FakeS3Client();
var options = Options.Create(new S3ArtifactStoreOptions { BucketName = "exports", Prefix = "vex" });
var store = new S3ArtifactStore(client, options, NullLogger<S3ArtifactStore>.Instance);
var content = new byte[] { 1, 2, 3, 4 };
var artifact = new VexExportArtifact(
new VexContentAddress("sha256", "deadbeef"),
VexExportFormat.Json,
content,
ImmutableDictionary<string, string>.Empty);
await store.SaveAsync(artifact, CancellationToken.None);
Assert.True(client.PutCalls.TryGetValue("exports", out var bucketEntries));
Assert.NotNull(bucketEntries);
var entry = bucketEntries!.Single();
Assert.Equal("vex/json/deadbeef.json", entry.Key);
Assert.Equal(content, entry.Content);
Assert.Equal("sha256:deadbeef", entry.Metadata["vex-digest"]);
}
[Fact]
public async Task OpenReadAsync_ReturnsStoredContent()
{
var client = new FakeS3Client();
var options = Options.Create(new S3ArtifactStoreOptions { BucketName = "exports", Prefix = "vex" });
var store = new S3ArtifactStore(client, options, NullLogger<S3ArtifactStore>.Instance);
var address = new VexContentAddress("sha256", "cafebabe");
client.SeedObject("exports", "vex/json/cafebabe.json", new byte[] { 9, 9, 9 });
var stream = await store.OpenReadAsync(address, CancellationToken.None);
Assert.NotNull(stream);
using var ms = new MemoryStream();
await stream!.CopyToAsync(ms);
Assert.Equal(new byte[] { 9, 9, 9 }, ms.ToArray());
}
private sealed class FakeS3Client : IS3ArtifactClient
{
public ConcurrentDictionary<string, List<S3Entry>> PutCalls { get; } = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<(string Bucket, string Key), byte[]> _storage = new();
public void SeedObject(string bucket, string key, byte[] content)
{
PutCalls.GetOrAdd(bucket, _ => new List<S3Entry>()).Add(new S3Entry(key, content, new Dictionary<string, string>()));
_storage[(bucket, key)] = content;
}
public Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken)
=> Task.FromResult(_storage.ContainsKey((bucketName, key)));
public Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
content.CopyTo(ms);
var bytes = ms.ToArray();
PutCalls.GetOrAdd(bucketName, _ => new List<S3Entry>()).Add(new S3Entry(key, bytes, new Dictionary<string, string>(metadata)));
_storage[(bucketName, key)] = bytes;
return Task.CompletedTask;
}
public Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
if (_storage.TryGetValue((bucketName, key), out var bytes))
{
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
return Task.FromResult<Stream?>(null);
}
public Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
_storage.TryRemove((bucketName, key), out _);
return Task.CompletedTask;
}
public readonly record struct S3Entry(string Key, byte[] Content, IDictionary<string, string> Metadata);
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.Export.Tests;
public sealed class VexExportCacheServiceTests
{
[Fact]
public async Task InvalidateAsync_RemovesEntry()
{
var cacheIndex = new RecordingIndex();
var maintenance = new StubMaintenance();
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
var signature = new VexQuerySignature("format=json|provider=vendor");
await service.InvalidateAsync(signature, VexExportFormat.Json, CancellationToken.None);
Assert.Equal(signature.Value, cacheIndex.LastSignature?.Value);
Assert.Equal(VexExportFormat.Json, cacheIndex.LastFormat);
Assert.Equal(1, cacheIndex.RemoveCalls);
}
[Fact]
public async Task PruneExpiredAsync_ReturnsCount()
{
var cacheIndex = new RecordingIndex();
var maintenance = new StubMaintenance { ExpiredCount = 3 };
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
var removed = await service.PruneExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Equal(3, removed);
}
[Fact]
public async Task PruneDanglingAsync_ReturnsCount()
{
var cacheIndex = new RecordingIndex();
var maintenance = new StubMaintenance { DanglingCount = 2 };
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
var removed = await service.PruneDanglingAsync(CancellationToken.None);
Assert.Equal(2, removed);
}
private sealed class RecordingIndex : IVexCacheIndex
{
public VexQuerySignature? LastSignature { get; private set; }
public VexExportFormat LastFormat { get; private set; }
public int RemoveCalls { get; private set; }
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<VexCacheEntry?>(null);
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
LastSignature = signature;
LastFormat = format;
RemoveCalls++;
return ValueTask.CompletedTask;
}
}
private sealed class StubMaintenance : IVexCacheMaintenance
{
public int ExpiredCount { get; set; }
public int DanglingCount { get; set; }
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(ExpiredCount);
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(DanglingCount);
}
}

View File

@@ -0,0 +1,131 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.IO;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CSAF;
namespace StellaOps.Excititor.Formats.CSAF.Tests;
public sealed class CsafNormalizerTests
{
[Fact]
public async Task NormalizeAsync_ProducesClaimsPerProductStatus()
{
var json = """
{
"document": {
"tracking": {
"id": "RHSA-2025:0001",
"version": "3",
"revision": "3",
"status": "final",
"initial_release_date": "2025-10-01T00:00:00Z",
"current_release_date": "2025-10-10T00:00:00Z"
},
"publisher": {
"name": "Red Hat Product Security",
"category": "vendor"
}
},
"product_tree": {
"full_product_names": [
{
"product_id": "CSAFPID-0001",
"name": "Red Hat Enterprise Linux 9",
"product_identification_helper": {
"cpe": "cpe:/o:redhat:enterprise_linux:9",
"purl": "pkg:rpm/redhat/enterprise-linux@9"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-0001",
"title": "Kernel vulnerability",
"product_status": {
"known_affected": [ "CSAFPID-0001" ]
}
},
{
"id": "VEX-0002",
"title": "Library issue",
"product_status": {
"known_not_affected": [ "CSAFPID-0001" ]
}
}
]
}
""";
var rawDocument = new VexRawDocument(
ProviderId: "excititor:redhat",
VexDocumentFormat.Csaf,
new Uri("https://example.com/csaf/rhsa-2025-0001.json"),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
"sha256:dummydigest",
Encoding.UTF8.GetBytes(json),
ImmutableDictionary<string, string>.Empty);
var provider = new VexProvider("excititor:redhat", "Red Hat CSAF", VexProviderKind.Distro);
var normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
batch.Claims.Should().HaveCount(2);
var affectedClaim = batch.Claims.First(c => c.VulnerabilityId == "CVE-2025-0001");
affectedClaim.Status.Should().Be(VexClaimStatus.Affected);
affectedClaim.Product.Key.Should().Be("CSAFPID-0001");
affectedClaim.Product.Purl.Should().Be("pkg:rpm/redhat/enterprise-linux@9");
affectedClaim.Product.Cpe.Should().Be("cpe:/o:redhat:enterprise_linux:9");
affectedClaim.Document.Revision.Should().Be("3");
affectedClaim.FirstSeen.Should().Be(new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero));
affectedClaim.LastSeen.Should().Be(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
affectedClaim.AdditionalMetadata.Should().ContainKey("csaf.tracking.id");
affectedClaim.AdditionalMetadata["csaf.publisher.name"].Should().Be("Red Hat Product Security");
var notAffectedClaim = batch.Claims.First(c => c.VulnerabilityId == "VEX-0002");
notAffectedClaim.Status.Should().Be(VexClaimStatus.NotAffected);
}
[Fact]
public async Task NormalizeAsync_PreservesRedHatSpecificMetadata()
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "rhsa-sample.json");
var json = await File.ReadAllTextAsync(path);
var rawDocument = new VexRawDocument(
ProviderId: "excititor:redhat",
VexDocumentFormat.Csaf,
new Uri("https://security.example.com/rhsa-2025-1001.json"),
new DateTimeOffset(2025, 10, 6, 0, 0, 0, TimeSpan.Zero),
"sha256:rhdadigest",
Encoding.UTF8.GetBytes(json),
ImmutableDictionary<string, string>.Empty);
var provider = new VexProvider("excititor:redhat", "Red Hat CSAF", VexProviderKind.Distro);
var normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
batch.Claims.Should().ContainSingle();
var claim = batch.Claims[0];
claim.VulnerabilityId.Should().Be("CVE-2025-1234");
claim.Status.Should().Be(VexClaimStatus.Affected);
claim.Product.Key.Should().Be("rh-enterprise-linux-9");
claim.Product.Name.Should().Be("Red Hat Enterprise Linux 9");
claim.Product.Purl.Should().Be("pkg:rpm/redhat/enterprise-linux@9");
claim.Product.Cpe.Should().Be("cpe:/o:redhat:enterprise_linux:9");
claim.FirstSeen.Should().Be(new DateTimeOffset(2025, 10, 1, 12, 0, 0, TimeSpan.Zero));
claim.LastSeen.Should().Be(new DateTimeOffset(2025, 10, 5, 10, 0, 0, TimeSpan.Zero));
claim.AdditionalMetadata.Should().ContainKey("csaf.tracking.id");
claim.AdditionalMetadata["csaf.tracking.id"].Should().Be("RHSA-2025:1001");
claim.AdditionalMetadata["csaf.tracking.status"].Should().Be("final");
claim.AdditionalMetadata["csaf.publisher.name"].Should().Be("Red Hat Product Security");
}
}

View File

@@ -0,0 +1,47 @@
{
"document": {
"publisher": {
"name": "Red Hat Product Security",
"category": "vendor"
},
"tracking": {
"id": "RHSA-2025:1001",
"status": "final",
"version": "3",
"initial_release_date": "2025-10-01T12:00:00Z",
"current_release_date": "2025-10-05T10:00:00Z"
}
},
"product_tree": {
"full_product_names": [
{
"product_id": "rh-enterprise-linux-9",
"name": "Red Hat Enterprise Linux 9",
"product_identification_helper": {
"cpe": "cpe:/o:redhat:enterprise_linux:9",
"purl": "pkg:rpm/redhat/enterprise-linux@9"
}
}
],
"branches": [
{
"name": "Red Hat Enterprise Linux",
"product": {
"product_id": "rh-enterprise-linux-9",
"name": "Red Hat Enterprise Linux 9"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-1234",
"title": "Kernel privilege escalation",
"product_status": {
"known_affected": [
"rh-enterprise-linux-9"
]
}
}
]
}

View File

@@ -0,0 +1,21 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxNormalizerTests
{
[Fact]
public async Task NormalizeAsync_MapsAnalysisStateAndJustification()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:1234",
"version": "7",
"metadata": {
"timestamp": "2025-10-15T12:00:00Z"
},
"components": [
{
"bom-ref": "pkg:npm/acme/lib@1.0.0",
"name": "acme-lib",
"version": "1.0.0",
"purl": "pkg:npm/acme/lib@1.0.0"
}
],
"vulnerabilities": [
{
"id": "CVE-2025-1000",
"detail": "Library issue",
"analysis": {
"state": "not_affected",
"justification": "code_not_present",
"response": [ "can_not_fix", "will_not_fix" ]
},
"affects": [
{ "ref": "pkg:npm/acme/lib@1.0.0" }
]
},
{
"id": "CVE-2025-1001",
"description": "Investigating impact",
"analysis": {
"state": "in_triage"
},
"affects": [
{ "ref": "pkg:npm/missing/component@2.0.0" }
]
}
]
}
""";
var rawDocument = new VexRawDocument(
"excititor:cyclonedx",
VexDocumentFormat.CycloneDx,
new Uri("https://example.org/vex.json"),
new DateTimeOffset(2025, 10, 16, 0, 0, 0, TimeSpan.Zero),
"sha256:dummydigest",
Encoding.UTF8.GetBytes(json),
ImmutableDictionary<string, string>.Empty);
var provider = new VexProvider("excititor:cyclonedx", "CycloneDX Provider", VexProviderKind.Vendor);
var normalizer = new CycloneDxNormalizer(NullLogger<CycloneDxNormalizer>.Instance);
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
batch.Claims.Should().HaveCount(2);
var notAffected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1000");
notAffected.Status.Should().Be(VexClaimStatus.NotAffected);
notAffected.Justification.Should().Be(VexJustification.CodeNotPresent);
notAffected.Product.Key.Should().Be("pkg:npm/acme/lib@1.0.0");
notAffected.Product.Purl.Should().Be("pkg:npm/acme/lib@1.0.0");
notAffected.Document.Revision.Should().Be("7");
notAffected.AdditionalMetadata["cyclonedx.specVersion"].Should().Be("1.4");
notAffected.AdditionalMetadata["cyclonedx.analysis.state"].Should().Be("not_affected");
notAffected.AdditionalMetadata["cyclonedx.analysis.response"].Should().Be("can_not_fix,will_not_fix");
var investigating = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1001");
investigating.Status.Should().Be(VexClaimStatus.UnderInvestigation);
investigating.Justification.Should().BeNull();
investigating.Product.Key.Should().Be("pkg:npm/missing/component@2.0.0");
investigating.Product.Name.Should().Be("pkg:npm/missing/component@2.0.0");
investigating.AdditionalMetadata.Should().ContainKey("cyclonedx.specVersion");
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CycloneDX/StellaOps.Excititor.Formats.CycloneDX.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexNormalizerTests
{
[Fact]
public async Task NormalizeAsync_ProducesClaimsForStatements()
{
var json = """
{
"document": {
"author": "Acme Security",
"version": "1",
"issued": "2025-10-01T00:00:00Z",
"last_updated": "2025-10-05T00:00:00Z"
},
"statements": [
{
"id": "statement-1",
"vulnerability": "CVE-2025-2000",
"status": "not_affected",
"justification": "code_not_present",
"products": [
{
"id": "acme-widget@1.2.3",
"name": "Acme Widget",
"version": "1.2.3",
"purl": "pkg:acme/widget@1.2.3",
"cpe": "cpe:/a:acme:widget:1.2.3"
}
],
"statement": "The vulnerable code was never shipped."
},
{
"id": "statement-2",
"vulnerability": "CVE-2025-2001",
"status": "affected",
"products": [
"pkg:acme/widget@2.0.0"
],
"remediation": "Upgrade to 2.1.0"
}
]
}
""";
var rawDocument = new VexRawDocument(
"excititor:openvex",
VexDocumentFormat.OpenVex,
new Uri("https://example.com/openvex.json"),
new DateTimeOffset(2025, 10, 6, 0, 0, 0, TimeSpan.Zero),
"sha256:dummydigest",
Encoding.UTF8.GetBytes(json),
ImmutableDictionary<string, string>.Empty);
var provider = new VexProvider("excititor:openvex", "OpenVEX Provider", VexProviderKind.Vendor);
var normalizer = new OpenVexNormalizer(NullLogger<OpenVexNormalizer>.Instance);
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
batch.Claims.Should().HaveCount(2);
var notAffected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-2000");
notAffected.Status.Should().Be(VexClaimStatus.NotAffected);
notAffected.Justification.Should().Be(VexJustification.CodeNotPresent);
notAffected.Product.Key.Should().Be("acme-widget@1.2.3");
notAffected.Product.Purl.Should().Be("pkg:acme/widget@1.2.3");
notAffected.Document.Revision.Should().Be("1");
notAffected.AdditionalMetadata["openvex.document.author"].Should().Be("Acme Security");
notAffected.AdditionalMetadata["openvex.statement.status"].Should().Be("not_affected");
notAffected.Detail.Should().Be("The vulnerable code was never shipped.");
var affected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-2001");
affected.Status.Should().Be(VexClaimStatus.Affected);
affected.Justification.Should().BeNull();
affected.Product.Key.Should().Be("pkg:acme/widget@2.0.0");
affected.Product.Name.Should().Be("pkg:acme/widget@2.0.0");
affected.Detail.Should().Be("Upgrade to 2.1.0");
}
}

View File

@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
using Xunit;
namespace StellaOps.Excititor.Policy.Tests;
public sealed class VexPolicyProviderTests
{
[Fact]
public void GetSnapshot_UsesDefaultsWhenOptionsMissing()
{
var provider = new VexPolicyProvider(
new OptionsMonitorStub(new VexPolicyOptions()),
NullLogger<VexPolicyProvider>.Instance);
var snapshot = provider.GetSnapshot();
Assert.Equal(VexConsensusPolicyOptions.BaselineVersion, snapshot.Version);
Assert.Empty(snapshot.Issues);
Assert.Equal(VexConsensusPolicyOptions.DefaultWeightCeiling, snapshot.ConsensusOptions.WeightCeiling);
Assert.Equal(VexConsensusPolicyOptions.DefaultAlpha, snapshot.ConsensusOptions.Alpha);
Assert.Equal(VexConsensusPolicyOptions.DefaultBeta, snapshot.ConsensusOptions.Beta);
var evaluator = new VexPolicyEvaluator(provider);
var consensusProvider = new VexProvider("vendor", "Vendor", VexProviderKind.Vendor);
var claim = new VexClaim(
"CVE-2025-0001",
"vendor",
new VexProduct("pkg:vendor/app", "app"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:test", new Uri("https://example.com")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow);
Assert.Equal(1.0, evaluator.GetProviderWeight(consensusProvider));
Assert.False(evaluator.IsClaimEligible(claim, consensusProvider, out var reason));
Assert.Equal("missing_justification", reason);
}
[Fact]
public void GetSnapshot_AppliesOverridesAndClampsInvalidValues()
{
var options = new VexPolicyOptions
{
Version = "custom/v1",
Weights = new VexPolicyWeightOptions
{
Vendor = 1.2,
Distro = 0.8,
},
ProviderOverrides = new Dictionary<string, double>
{
["vendor"] = 0.95,
[" "] = 0.5,
},
};
var provider = new VexPolicyProvider(new OptionsMonitorStub(options), NullLogger<VexPolicyProvider>.Instance);
var snapshot = provider.GetSnapshot();
Assert.Equal("custom/v1", snapshot.Version);
Assert.NotEmpty(snapshot.Issues);
Assert.Equal(0.95, snapshot.ConsensusOptions.ProviderOverrides["vendor"]);
Assert.Contains(snapshot.Issues, issue => issue.Code == "weights.vendor.range");
Assert.Equal(VexConsensusPolicyOptions.DefaultWeightCeiling, snapshot.ConsensusOptions.WeightCeiling);
Assert.Equal(1.0, snapshot.ConsensusOptions.VendorWeight);
var evaluator = new VexPolicyEvaluator(provider);
var vendor = new VexProvider("vendor", "Vendor", VexProviderKind.Vendor);
Assert.Equal(0.95, evaluator.GetProviderWeight(vendor));
}
private sealed class OptionsMonitorStub : IOptionsMonitor<VexPolicyOptions>
{
private readonly VexPolicyOptions _value;
public OptionsMonitorStub(VexPolicyOptions value)
{
_value = value;
}
public VexPolicyOptions CurrentValue => _value;
public VexPolicyOptions Get(string? name) => _value;
public IDisposable OnChange(Action<VexPolicyOptions, string> listener) => DisposableAction.Instance;
private sealed class DisposableAction : IDisposable
{
public static readonly DisposableAction Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public MongoVexCacheMaintenanceTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("vex-cache-maintenance-tests");
VexMongoMappingRegistry.Register();
}
[Fact]
public async Task RemoveExpiredAsync_DeletesEntriesBeforeCutoff()
{
var collection = _database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
var now = DateTime.UtcNow;
await collection.InsertManyAsync(new[]
{
new VexCacheEntryRecord
{
Id = "sig-1|json",
QuerySignature = "sig-1",
Format = "json",
ArtifactAlgorithm = "sha256",
ArtifactDigest = "deadbeef",
CreatedAt = now.AddHours(-2),
ExpiresAt = now.AddHours(-1),
},
new VexCacheEntryRecord
{
Id = "sig-2|json",
QuerySignature = "sig-2",
Format = "json",
ArtifactAlgorithm = "sha256",
ArtifactDigest = "cafebabe",
CreatedAt = now,
ExpiresAt = now.AddHours(1),
},
});
var maintenance = new MongoVexCacheMaintenance(_database, NullLogger<MongoVexCacheMaintenance>.Instance);
var removed = await maintenance.RemoveExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Equal(1, removed);
var remaining = await collection.CountDocumentsAsync(FilterDefinition<VexCacheEntryRecord>.Empty);
Assert.Equal(1, remaining);
}
[Fact]
public async Task RemoveMissingManifestReferencesAsync_DropsDanglingEntries()
{
var cache = _database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
var exports = _database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports);
await exports.InsertOneAsync(new VexExportManifestRecord
{
Id = "manifest-existing",
QuerySignature = "sig-keep",
Format = "json",
CreatedAt = DateTime.UtcNow,
ArtifactAlgorithm = "sha256",
ArtifactDigest = "keep",
ClaimCount = 1,
SourceProviders = new List<string> { "vendor" },
});
await cache.InsertManyAsync(new[]
{
new VexCacheEntryRecord
{
Id = "sig-remove|json",
QuerySignature = "sig-remove",
Format = "json",
ArtifactAlgorithm = "sha256",
ArtifactDigest = "drop",
CreatedAt = DateTime.UtcNow,
ManifestId = "manifest-missing",
},
new VexCacheEntryRecord
{
Id = "sig-keep|json",
QuerySignature = "sig-keep",
Format = "json",
ArtifactAlgorithm = "sha256",
ArtifactDigest = "keep",
CreatedAt = DateTime.UtcNow,
ManifestId = "manifest-existing",
},
});
var maintenance = new MongoVexCacheMaintenance(_database, NullLogger<MongoVexCacheMaintenance>.Instance);
var removed = await maintenance.RemoveMissingManifestReferencesAsync(CancellationToken.None);
Assert.Equal(1, removed);
var remainingIds = await cache.Find(Builders<VexCacheEntryRecord>.Filter.Empty)
.Project(x => x.Id)
.ToListAsync();
Assert.Single(remainingIds);
Assert.Contains("sig-keep|json", remainingIds);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,344 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Aoc;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly MongoClient _client;
public MongoVexRepositoryTests()
{
_runner = MongoDbRunner.Start();
_client = new MongoClient(_runner.ConnectionString);
}
[Fact]
public async Task RawStore_UsesGridFsForLargePayloads()
{
var database = _client.GetDatabase($"vex-raw-gridfs-{Guid.NewGuid():N}");
var store = CreateRawStore(database, thresholdBytes: 32);
var payload = CreateJsonPayload(new string('A', 256));
var document = new VexRawDocument(
"red-hat",
VexDocumentFormat.Csaf,
new Uri("https://example.com/redhat/csaf.json"),
DateTimeOffset.UtcNow,
"sha256:large",
payload,
ImmutableDictionary<string, string>.Empty);
await store.StoreAsync(document, CancellationToken.None);
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var stored = await rawCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", document.Digest))
.FirstOrDefaultAsync();
Assert.NotNull(stored);
Assert.True(stored!.TryGetValue("GridFsObjectId", out var gridId));
Assert.False(gridId.IsBsonNull);
Assert.Empty(stored["Content"].AsBsonBinaryData.Bytes);
var filesCollection = database.GetCollection<BsonDocument>("vex.raw.files");
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(1, fileCount);
var fetched = await store.FindByDigestAsync(document.Digest, CancellationToken.None);
Assert.NotNull(fetched);
Assert.Equal(payload, fetched!.Content.ToArray());
}
[Fact]
public async Task RawStore_ReplacesGridFsWithInlinePayload()
{
var database = _client.GetDatabase($"vex-raw-inline-{Guid.NewGuid():N}");
var store = CreateRawStore(database, thresholdBytes: 16);
var largePayload = CreateJsonPayload(new string('B', 128));
var digest = "sha256:inline";
var largeDocument = new VexRawDocument(
"cisco",
VexDocumentFormat.CycloneDx,
new Uri("https://example.com/cyclonedx.json"),
DateTimeOffset.UtcNow,
digest,
largePayload,
ImmutableDictionary<string, string>.Empty);
await store.StoreAsync(largeDocument, CancellationToken.None);
var smallDocument = largeDocument with
{
RetrievedAt = DateTimeOffset.UtcNow.AddMinutes(1),
Content = CreateJsonPayload("small"),
};
await store.StoreAsync(smallDocument, CancellationToken.None);
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var stored = await rawCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", digest))
.FirstOrDefaultAsync();
Assert.NotNull(stored);
Assert.True(stored!.TryGetValue("GridFsObjectId", out var gridId));
Assert.True(gridId.IsBsonNull);
var storedContent = Encoding.UTF8.GetString(stored["Content"].AsBsonBinaryData.Bytes);
Assert.Equal(CreateJsonPayloadString("small"), storedContent);
var filesCollection = database.GetCollection<BsonDocument>("vex.raw.files");
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(0, fileCount);
}
[Fact]
public async Task RawStore_WhenGuardRejectsDocument_DoesNotPersist()
{
var database = _client.GetDatabase($"vex-raw-guard-{Guid.NewGuid():N}");
var guard = new RecordingVexRawWriteGuard { ShouldThrow = true };
var store = CreateRawStore(database, thresholdBytes: 64, guard);
var payload = CreateJsonPayload("guard-check");
var document = new VexRawDocument(
"vendor.guard",
VexDocumentFormat.Csaf,
new Uri("https://example.com/guard.json"),
DateTimeOffset.UtcNow,
"sha256:guard",
payload,
ImmutableDictionary<string, string>.Empty);
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => store.StoreAsync(document, CancellationToken.None).AsTask());
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var count = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(0, count);
Assert.NotNull(guard.LastDocument);
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
}
[Fact]
public async Task ExportStore_SavesManifestAndCacheTransactionally()
{
var database = _client.GetDatabase($"vex-export-save-{Guid.NewGuid():N}");
var options = Options.Create(new VexMongoStorageOptions
{
ExportCacheTtl = TimeSpan.FromHours(6),
GridFsInlineThresholdBytes = 64,
});
var sessionProvider = new VexMongoSessionProvider(_client, options);
var store = new MongoVexExportStore(_client, database, options, sessionProvider);
var signature = new VexQuerySignature("format=csaf|provider=redhat");
var manifest = new VexExportManifest(
"exports/20251016/redhat",
signature,
VexExportFormat.Csaf,
DateTimeOffset.UtcNow,
new VexContentAddress("sha256", "abcdef123456"),
claimCount: 5,
sourceProviders: new[] { "red-hat" },
fromCache: false,
consensusRevision: "rev-1",
attestation: null,
sizeBytes: 1024);
await store.SaveAsync(manifest, CancellationToken.None);
var exportsCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Exports);
var exportKey = BuildExportKey(signature, VexExportFormat.Csaf);
var exportDoc = await exportsCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", exportKey))
.FirstOrDefaultAsync();
Assert.NotNull(exportDoc);
var cacheCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
var cacheKey = BuildExportKey(signature, VexExportFormat.Csaf);
var cacheDoc = await cacheCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", cacheKey))
.FirstOrDefaultAsync();
Assert.NotNull(cacheDoc);
Assert.Equal(manifest.ExportId, cacheDoc!["ManifestId"].AsString);
Assert.True(cacheDoc.TryGetValue("ExpiresAt", out var expiresValue));
Assert.False(expiresValue.IsBsonNull);
}
[Fact]
public async Task ExportStore_FindAsync_ExpiresCacheEntries()
{
var database = _client.GetDatabase($"vex-export-expire-{Guid.NewGuid():N}");
var options = Options.Create(new VexMongoStorageOptions
{
ExportCacheTtl = TimeSpan.FromMinutes(5),
GridFsInlineThresholdBytes = 64,
});
var sessionProvider = new VexMongoSessionProvider(_client, options);
var store = new MongoVexExportStore(_client, database, options, sessionProvider);
var signature = new VexQuerySignature("format=json|provider=cisco");
var manifest = new VexExportManifest(
"exports/20251016/cisco",
signature,
VexExportFormat.Json,
DateTimeOffset.UtcNow,
new VexContentAddress("sha256", "deadbeef"),
claimCount: 3,
sourceProviders: new[] { "cisco" },
fromCache: false,
consensusRevision: "rev-2",
attestation: null,
sizeBytes: 2048);
await store.SaveAsync(manifest, CancellationToken.None);
var cacheCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
var cacheId = BuildExportKey(signature, VexExportFormat.Json);
var update = Builders<BsonDocument>.Update.Set("ExpiresAt", DateTime.UtcNow.AddMinutes(-10));
await cacheCollection.UpdateOneAsync(Builders<BsonDocument>.Filter.Eq("_id", cacheId), update);
var cached = await store.FindAsync(signature, VexExportFormat.Json, CancellationToken.None);
Assert.Null(cached);
var remaining = await cacheCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", cacheId))
.FirstOrDefaultAsync();
Assert.Null(remaining);
}
[Fact]
public async Task ClaimStore_AppendsAndQueriesStatements()
{
var database = _client.GetDatabase($"vex-claims-{Guid.NewGuid():N}");
var store = new MongoVexClaimStore(database);
var product = new VexProduct("pkg:demo/app", "Demo App", version: "1.0.0", purl: "pkg:demo/app@1.0.0");
var document = new VexClaimDocument(
VexDocumentFormat.Csaf,
"sha256:claim-1",
new Uri("https://example.org/vex/claim-1.json"),
revision: "2025-10-19");
var initialClaim = new VexClaim(
vulnerabilityId: "CVE-2025-0101",
providerId: "redhat",
product: product,
status: VexClaimStatus.NotAffected,
document: document,
firstSeen: DateTimeOffset.UtcNow.AddMinutes(-30),
lastSeen: DateTimeOffset.UtcNow.AddMinutes(-10),
justification: VexJustification.ComponentNotPresent,
detail: "Package not shipped in this channel.",
confidence: new VexConfidence("high", 0.9, "policy/default"),
signals: new VexSignalSnapshot(
new VexSeveritySignal("CVSS:3.1", 5.8, "medium", "CVSS:3.1/..."),
kev: false,
epss: 0.21),
additionalMetadata: ImmutableDictionary<string, string>.Empty.Add("source", "csaf"));
await store.AppendAsync(new[] { initialClaim }, DateTimeOffset.UtcNow.AddMinutes(-5), CancellationToken.None);
var secondDocument = new VexClaimDocument(
VexDocumentFormat.Csaf,
"sha256:claim-2",
new Uri("https://example.org/vex/claim-2.json"),
revision: "2025-10-19.1");
var secondClaim = new VexClaim(
vulnerabilityId: initialClaim.VulnerabilityId,
providerId: initialClaim.ProviderId,
product: initialClaim.Product,
status: initialClaim.Status,
document: secondDocument,
firstSeen: initialClaim.FirstSeen,
lastSeen: DateTimeOffset.UtcNow,
justification: initialClaim.Justification,
detail: initialClaim.Detail,
confidence: initialClaim.Confidence,
signals: new VexSignalSnapshot(
new VexSeveritySignal("CVSS:3.1", 7.2, "high"),
kev: true,
epss: 0.43),
additionalMetadata: initialClaim.AdditionalMetadata.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value));
await store.AppendAsync(new[] { secondClaim }, DateTimeOffset.UtcNow, CancellationToken.None);
var all = await store.FindAsync("CVE-2025-0101", product.Key, since: null, CancellationToken.None);
var allList = all.ToList();
Assert.Equal(2, allList.Count);
Assert.Equal("sha256:claim-2", allList[0].Document.Digest);
Assert.True(allList[0].Signals?.Kev);
Assert.Equal(0.43, allList[0].Signals?.Epss);
Assert.Equal("sha256:claim-1", allList[1].Document.Digest);
Assert.Equal("csaf", allList[1].AdditionalMetadata["source"]);
var recentOnly = await store.FindAsync("CVE-2025-0101", product.Key, DateTimeOffset.UtcNow.AddMinutes(-2), CancellationToken.None);
var recentList = recentOnly.ToList();
Assert.Single(recentList);
Assert.Equal("sha256:claim-2", recentList[0].Document.Digest);
}
private MongoVexRawStore CreateRawStore(IMongoDatabase database, int thresholdBytes, IVexRawWriteGuard? guard = null)
{
var options = Options.Create(new VexMongoStorageOptions
{
RawBucketName = "vex.raw",
GridFsInlineThresholdBytes = thresholdBytes,
ExportCacheTtl = TimeSpan.FromHours(1),
});
var sessionProvider = new VexMongoSessionProvider(_client, options);
var guardInstance = guard ?? new PassthroughVexRawWriteGuard();
return new MongoVexRawStore(_client, database, options, sessionProvider, guardInstance);
}
private static string BuildExportKey(VexQuerySignature signature, VexExportFormat format)
=> string.Format(CultureInfo.InvariantCulture, "{0}|{1}", signature.Value, format.ToString().ToLowerInvariant());
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
private static byte[] CreateJsonPayload(string value)
=> Encoding.UTF8.GetBytes(CreateJsonPayloadString(value));
private static string CreateJsonPayloadString(string value)
=> $"{{\"data\":\"{value}\"}}";
private sealed class RecordingVexRawWriteGuard : IVexRawWriteGuard
{
public bool ShouldThrow { get; set; }
public RawVexDocumentModel? LastDocument { get; private set; }
public void EnsureValid(RawVexDocumentModel document)
{
LastDocument = document;
if (ShouldThrow)
{
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Guard rejected document.");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
}
private sealed class PassthroughVexRawWriteGuard : IVexRawWriteGuard
{
public void EnsureValid(RawVexDocumentModel document)
{
// No-op guard for unit tests.
}
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
public MongoVexSessionConsistencyTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
}
[Fact]
public async Task SessionProvidesReadYourWrites()
{
await using var provider = BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var sessionProvider = scope.ServiceProvider.GetRequiredService<IVexMongoSessionProvider>();
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
var session = await sessionProvider.StartSessionAsync();
var descriptor = new VexProvider("red-hat", "Red Hat", VexProviderKind.Vendor);
await providerStore.SaveAsync(descriptor, CancellationToken.None, session);
var fetched = await providerStore.FindAsync(descriptor.Id, CancellationToken.None, session);
Assert.NotNull(fetched);
Assert.Equal(descriptor.DisplayName, fetched!.DisplayName);
}
[Fact]
public async Task SessionMaintainsMonotonicReadsAcrossStepDown()
{
await using var provider = BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IMongoClient>();
var sessionProvider = scope.ServiceProvider.GetRequiredService<IVexMongoSessionProvider>();
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
var session = await sessionProvider.StartSessionAsync();
var initial = new VexProvider("cisco", "Cisco", VexProviderKind.Vendor);
await providerStore.SaveAsync(initial, CancellationToken.None, session);
var baseline = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
Assert.Equal("Cisco", baseline!.DisplayName);
await ForcePrimaryStepDownAsync(client, CancellationToken.None);
await WaitForPrimaryAsync(client, CancellationToken.None);
await ExecuteWithRetryAsync(async () =>
{
var updated = new VexProvider(initial.Id, "Cisco Systems", initial.Kind);
await providerStore.SaveAsync(updated, CancellationToken.None, session);
}, CancellationToken.None);
var afterFailover = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
Assert.Equal("Cisco Systems", afterFailover!.DisplayName);
var subsequent = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
Assert.Equal("Cisco Systems", subsequent!.DisplayName);
}
private ServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddDebug());
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _runner.ConnectionString;
options.DatabaseName = $"excititor-session-tests-{Guid.NewGuid():N}";
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
});
services.AddExcititorMongoStorage();
return services.BuildServiceProvider();
}
private static async Task ExecuteWithRetryAsync(Func<Task> action, CancellationToken cancellationToken)
{
const int maxAttempts = 10;
var attempt = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await action();
return;
}
catch (MongoException ex) when (IsStepDownTransient(ex) && attempt++ < maxAttempts)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
}
}
}
private static bool IsStepDownTransient(MongoException ex)
{
if (ex is MongoConnectionException)
{
return true;
}
if (ex is MongoCommandException command)
{
return command.Code is 7 or 89 or 91 or 10107 or 11600
|| string.Equals(command.CodeName, "NotPrimaryNoSecondaryOk", StringComparison.OrdinalIgnoreCase)
|| string.Equals(command.CodeName, "NotWritablePrimary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(command.CodeName, "PrimarySteppedDown", StringComparison.OrdinalIgnoreCase)
|| string.Equals(command.CodeName, "NotPrimary", StringComparison.OrdinalIgnoreCase);
}
return false;
}
private static async Task ForcePrimaryStepDownAsync(IMongoClient client, CancellationToken cancellationToken)
{
var admin = client.GetDatabase("admin");
var command = new BsonDocument
{
{ "replSetStepDown", 1 },
{ "force", true },
};
try
{
await admin.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
}
catch (MongoException ex) when (IsStepDownTransient(ex))
{
// Expected when the primary closes connections during the step-down sequence.
}
}
private static async Task WaitForPrimaryAsync(IMongoClient client, CancellationToken cancellationToken)
{
var admin = client.GetDatabase("admin");
var helloCommand = new BsonDocument("hello", 1);
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var result = await admin.RunCommandAsync<BsonDocument>(helloCommand, cancellationToken: cancellationToken);
if (result.TryGetValue("isWritablePrimary", out var value) && value.IsBoolean && value.AsBoolean)
{
return;
}
}
catch (MongoException ex) when (IsStepDownTransient(ex))
{
// Primary still recovering, retry.
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
}
throw new TimeoutException("Replica set primary did not recover in time.");
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Mongo2Go;
using System.Text;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
public MongoVexStatementBackfillServiceTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
}
[Fact]
public async Task RunAsync_BackfillsStatementsFromRawDocuments()
{
await using var provider = BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var rawStore = scope.ServiceProvider.GetRequiredService<IVexRawStore>();
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
var backfill = scope.ServiceProvider.GetRequiredService<VexStatementBackfillService>();
var retrievedAt = DateTimeOffset.UtcNow.AddMinutes(-15);
var metadata = ImmutableDictionary<string, string>.Empty
.Add("vulnId", "CVE-2025-0001")
.Add("productKey", "pkg:test/app");
var document = new VexRawDocument(
"test-provider",
VexDocumentFormat.Csaf,
new Uri("https://example.test/vex.json"),
retrievedAt,
"sha256:test-doc",
CreateJsonPayload("backfill-1"),
metadata);
await rawStore.StoreAsync(document, CancellationToken.None);
var result = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
Assert.Equal(1, result.DocumentsEvaluated);
Assert.Equal(1, result.DocumentsBackfilled);
Assert.Equal(1, result.ClaimsWritten);
Assert.Equal(0, result.NormalizationFailures);
var claims = await claimStore.FindAsync("CVE-2025-0001", "pkg:test/app", since: null, CancellationToken.None);
var claim = Assert.Single(claims);
Assert.Equal(VexClaimStatus.NotAffected, claim.Status);
Assert.Equal("test-provider", claim.ProviderId);
Assert.Equal(retrievedAt.ToUnixTimeSeconds(), claim.FirstSeen.ToUnixTimeSeconds());
Assert.NotNull(claim.Signals);
Assert.Equal(0.2, claim.Signals!.Epss);
Assert.Equal("cvss", claim.Signals!.Severity?.Scheme);
}
[Fact]
public async Task RunAsync_SkipsExistingDocumentsUnlessForced()
{
await using var provider = BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var rawStore = scope.ServiceProvider.GetRequiredService<IVexRawStore>();
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
var backfill = scope.ServiceProvider.GetRequiredService<VexStatementBackfillService>();
var metadata = ImmutableDictionary<string, string>.Empty
.Add("vulnId", "CVE-2025-0002")
.Add("productKey", "pkg:test/api");
var document = new VexRawDocument(
"test-provider",
VexDocumentFormat.Csaf,
new Uri("https://example.test/vex-2.json"),
DateTimeOffset.UtcNow.AddMinutes(-10),
"sha256:test-doc-2",
CreateJsonPayload("backfill-2"),
metadata);
await rawStore.StoreAsync(document, CancellationToken.None);
var first = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
Assert.Equal(1, first.DocumentsBackfilled);
var second = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
Assert.Equal(1, second.DocumentsEvaluated);
Assert.Equal(0, second.DocumentsBackfilled);
Assert.Equal(1, second.SkippedExisting);
var forced = await backfill.RunAsync(new VexStatementBackfillRequest(Force: true), CancellationToken.None);
Assert.Equal(1, forced.DocumentsBackfilled);
var claims = await claimStore.FindAsync("CVE-2025-0002", "pkg:test/api", since: null, CancellationToken.None);
Assert.Equal(2, claims.Count);
}
private ServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddDebug());
services.AddSingleton(TimeProvider.System);
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _runner.ConnectionString;
options.DatabaseName = $"excititor-backfill-tests-{Guid.NewGuid():N}";
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
options.GridFsInlineThresholdBytes = 1024;
options.ExportCacheTtl = TimeSpan.FromHours(1);
});
services.AddExcititorMongoStorage();
services.AddExcititorAocGuards();
services.AddSingleton<IVexNormalizer, TestNormalizer>();
return services.BuildServiceProvider();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
private static ReadOnlyMemory<byte> CreateJsonPayload(string value)
=> Encoding.UTF8.GetBytes($"{{\"data\":\"{value}\"}}");
private sealed class TestNormalizer : IVexNormalizer
{
public string Format => "csaf";
public bool CanHandle(VexRawDocument document) => true;
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken)
{
var productKey = document.Metadata.TryGetValue("productKey", out var value) ? value : "pkg:test/default";
var vulnId = document.Metadata.TryGetValue("vulnId", out var vuln) ? vuln : "CVE-TEST-0000";
var product = new VexProduct(productKey, "Test Product");
var claimDocument = new VexClaimDocument(
document.Format,
document.Digest,
document.SourceUri);
var timestamp = document.RetrievedAt == default ? DateTimeOffset.UtcNow : document.RetrievedAt;
var claim = new VexClaim(
vulnId,
provider.Id,
product,
VexClaimStatus.NotAffected,
claimDocument,
timestamp,
timestamp,
VexJustification.ComponentNotPresent,
detail: "backfill-test",
confidence: new VexConfidence("high", 0.95, "unit-test"),
signals: new VexSignalSnapshot(
new VexSeveritySignal("cvss", 5.4, "medium"),
kev: false,
epss: 0.2));
var claims = ImmutableArray.Create(claim);
return ValueTask.FromResult(new VexClaimBatch(document, claims, ImmutableDictionary<string, string>.Empty));
}
}
}

View File

@@ -0,0 +1,267 @@
using System.Globalization;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStoreMappingTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public MongoVexStoreMappingTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("excititor-storage-mapping-tests");
VexMongoMappingRegistry.Register();
}
[Fact]
public async Task ProviderStore_RoundTrips_WithExtraFields()
{
var providers = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Providers);
var providerId = "red-hat";
var document = new BsonDocument
{
{ "_id", providerId },
{ "DisplayName", "Red Hat CSAF" },
{ "Kind", "vendor" },
{ "BaseUris", new BsonArray { "https://example.com/csaf" } },
{
"Discovery",
new BsonDocument
{
{ "WellKnownMetadata", "https://example.com/.well-known/csaf" },
{ "RolIeService", "https://example.com/service/rolie" },
{ "UnsupportedField", "ignored" },
}
},
{
"Trust",
new BsonDocument
{
{ "Weight", 0.75 },
{
"Cosign",
new BsonDocument
{
{ "Issuer", "issuer@example.com" },
{ "IdentityPattern", "spiffe://example/*" },
{ "Unexpected", true },
}
},
{ "PgpFingerprints", new BsonArray { "ABCDEF1234567890" } },
{ "AnotherIgnoredField", 123 },
}
},
{ "Enabled", true },
{ "UnexpectedRoot", new BsonDocument { { "flag", true } } },
};
await providers.InsertOneAsync(document);
var store = new MongoVexProviderStore(_database);
var result = await store.FindAsync(providerId, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(providerId, result!.Id);
Assert.Equal("Red Hat CSAF", result.DisplayName);
Assert.Equal(VexProviderKind.Vendor, result.Kind);
Assert.Single(result.BaseUris);
Assert.Equal("https://example.com/csaf", result.BaseUris[0].ToString());
Assert.Equal("https://example.com/.well-known/csaf", result.Discovery.WellKnownMetadata?.ToString());
Assert.Equal("https://example.com/service/rolie", result.Discovery.RolIeService?.ToString());
Assert.Equal(0.75, result.Trust.Weight);
Assert.NotNull(result.Trust.Cosign);
Assert.Equal("issuer@example.com", result.Trust.Cosign!.Issuer);
Assert.Equal("spiffe://example/*", result.Trust.Cosign!.IdentityPattern);
Assert.Contains("ABCDEF1234567890", result.Trust.PgpFingerprints);
Assert.True(result.Enabled);
}
[Fact]
public async Task ConsensusStore_IgnoresUnknownFields()
{
var consensus = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Consensus);
var vulnerabilityId = "CVE-2025-12345";
var productKey = "pkg:maven/org.example/app@1.2.3";
var consensusId = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", vulnerabilityId.Trim(), productKey.Trim());
var document = new BsonDocument
{
{ "_id", consensusId },
{ "VulnerabilityId", vulnerabilityId },
{
"Product",
new BsonDocument
{
{ "Key", productKey },
{ "Name", "Example App" },
{ "Version", "1.2.3" },
{ "Purl", productKey },
{ "Extra", "ignored" },
}
},
{ "Status", "notaffected" },
{ "CalculatedAt", DateTime.UtcNow },
{
"Sources",
new BsonArray
{
new BsonDocument
{
{ "ProviderId", "red-hat" },
{ "Status", "notaffected" },
{ "DocumentDigest", "sha256:123" },
{ "Weight", 0.9 },
{ "Justification", "componentnotpresent" },
{ "Detail", "Vendor statement" },
{
"Confidence",
new BsonDocument
{
{ "Level", "high" },
{ "Score", 0.7 },
{ "Method", "review" },
{ "Unexpected", "ignored" },
}
},
{ "UnknownField", true },
},
}
},
{
"Conflicts",
new BsonArray
{
new BsonDocument
{
{ "ProviderId", "cisco" },
{ "Status", "affected" },
{ "DocumentDigest", "sha256:999" },
{ "Justification", "requiresconfiguration" },
{ "Detail", "Different guidance" },
{ "Reason", "policy_override" },
{ "Other", 1 },
},
}
},
{
"Signals",
new BsonDocument
{
{
"Severity",
new BsonDocument
{
{ "Scheme", "CVSS:3.1" },
{ "Score", 7.5 },
{ "Label", "high" },
{ "Vector", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
}
},
{ "Kev", true },
{ "Epss", 0.42 },
}
},
{ "PolicyVersion", "2025.10" },
{ "PolicyRevisionId", "rev-1" },
{ "PolicyDigest", "sha256:abc" },
{ "Summary", "Vendor confirms not affected." },
{ "GeneratedAt", DateTime.UtcNow },
{ "Unexpected", new BsonDocument { { "foo", "bar" } } },
};
await consensus.InsertOneAsync(document);
var store = new MongoVexConsensusStore(_database);
var result = await store.FindAsync(vulnerabilityId, productKey, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(vulnerabilityId, result!.VulnerabilityId);
Assert.Equal(productKey, result.Product.Key);
Assert.Equal("Example App", result.Product.Name);
Assert.Equal(VexConsensusStatus.NotAffected, result.Status);
Assert.Single(result.Sources);
var source = result.Sources[0];
Assert.Equal("red-hat", source.ProviderId);
Assert.Equal(VexClaimStatus.NotAffected, source.Status);
Assert.Equal("sha256:123", source.DocumentDigest);
Assert.Equal(0.9, source.Weight);
Assert.Equal(VexJustification.ComponentNotPresent, source.Justification);
Assert.NotNull(source.Confidence);
Assert.Equal("high", source.Confidence!.Level);
Assert.Equal(0.7, source.Confidence!.Score);
Assert.Equal("review", source.Confidence!.Method);
Assert.Single(result.Conflicts);
var conflict = result.Conflicts[0];
Assert.Equal("cisco", conflict.ProviderId);
Assert.Equal(VexClaimStatus.Affected, conflict.Status);
Assert.Equal(VexJustification.RequiresConfiguration, conflict.Justification);
Assert.Equal("policy_override", conflict.Reason);
Assert.Equal("Vendor confirms not affected.", result.Summary);
Assert.Equal("2025.10", result.PolicyVersion);
Assert.NotNull(result.Signals);
Assert.True(result.Signals!.Kev);
Assert.Equal(0.42, result.Signals.Epss);
Assert.NotNull(result.Signals.Severity);
Assert.Equal("CVSS:3.1", result.Signals.Severity!.Scheme);
Assert.Equal(7.5, result.Signals.Severity.Score);
}
[Fact]
public async Task CacheIndex_RoundTripsGridFsMetadata()
{
var gridObjectId = ObjectId.GenerateNewId().ToString();
var index = new MongoVexCacheIndex(_database);
var signature = new VexQuerySignature("format=csaf|vendor=redhat");
var now = DateTimeOffset.UtcNow;
var expires = now.AddHours(12);
var entry = new VexCacheEntry(
signature,
VexExportFormat.Csaf,
new VexContentAddress("sha256", "abcdef123456"),
now,
sizeBytes: 1024,
manifestId: "manifest-001",
gridFsObjectId: gridObjectId,
expiresAt: expires);
await index.SaveAsync(entry, CancellationToken.None);
var cacheId = string.Format(
CultureInfo.InvariantCulture,
"{0}|{1}",
signature.Value,
entry.Format.ToString().ToLowerInvariant());
var cache = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
var filter = Builders<BsonDocument>.Filter.Eq("_id", cacheId);
var update = Builders<BsonDocument>.Update.Set("UnexpectedField", true);
await cache.UpdateOneAsync(filter, update);
var roundTrip = await index.FindAsync(signature, VexExportFormat.Csaf, CancellationToken.None);
Assert.NotNull(roundTrip);
Assert.Equal(entry.QuerySignature.Value, roundTrip!.QuerySignature.Value);
Assert.Equal(entry.Format, roundTrip.Format);
Assert.Equal(entry.Artifact.Digest, roundTrip.Artifact.Digest);
Assert.Equal(entry.ManifestId, roundTrip.ManifestId);
Assert.Equal(entry.GridFsObjectId, roundTrip.GridFsObjectId);
Assert.Equal(entry.SizeBytes, roundTrip.SizeBytes);
Assert.NotNull(roundTrip.ExpiresAt);
Assert.Equal(expires.ToUnixTimeMilliseconds(), roundTrip.ExpiresAt!.Value.ToUnixTimeMilliseconds());
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Excititor.Storage.Mongo.Migrations;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public VexMongoMigrationRunnerTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("excititor-migrations-tests");
}
[Fact]
public async Task RunAsync_AppliesInitialIndexesOnce()
{
var migrations = new IVexMongoMigration[]
{
new VexInitialIndexMigration(),
new VexConsensusSignalsMigration(),
};
var runner = new VexMongoMigrationRunner(_database, migrations, NullLogger<VexMongoMigrationRunner>.Instance);
await runner.RunAsync(CancellationToken.None);
await runner.RunAsync(CancellationToken.None);
var appliedCollection = _database.GetCollection<VexMigrationRecord>(VexMongoCollectionNames.Migrations);
var applied = await appliedCollection.Find(FilterDefinition<VexMigrationRecord>.Empty).ToListAsync();
Assert.Equal(2, applied.Count);
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
Assert.True(HasIndex(_database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw), "ProviderId_1_Format_1_RetrievedAt_1"));
Assert.True(HasIndex(_database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers), "Kind_1"));
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "VulnerabilityId_1_Product.Key_1"));
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_PolicyDigest_1"));
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_CalculatedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports), "QuerySignature_1_Format_1"));
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "QuerySignature_1_Format_1"));
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
}
private static bool HasIndex<TDocument>(IMongoCollection<TDocument> collection, string name)
{
var indexes = collection.Indexes.List().ToList();
return indexes.Any(index => index["name"].AsString == name);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,274 @@
using System.Collections.Immutable;
using System.IO;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class IngestEndpointsTests
{
private readonly FakeIngestOrchestrator _orchestrator = new();
private readonly TimeProvider _timeProvider = TimeProvider.System;
[Fact]
public async Task InitEndpoint_ReturnsUnauthorized_WhenMissingToken()
{
var httpContext = CreateHttpContext();
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
Assert.IsType<UnauthorizedHttpResult>(result);
}
[Fact]
public async Task InitEndpoint_ReturnsForbidden_WhenScopeMissing()
{
var httpContext = CreateHttpContext("vex.read");
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
Assert.IsType<ForbidHttpResult>(result);
}
[Fact]
public async Task InitEndpoint_NormalizesProviders_AndReturnsSummary()
{
var httpContext = CreateHttpContext("vex.admin");
var request = new IngestEndpoints.ExcititorInitRequest(new[] { " suse ", "redhat", "REDHAT" }, true);
var started = DateTimeOffset.Parse("2025-10-20T12:00:00Z");
var completed = started.AddMinutes(2);
_orchestrator.InitFactory = options => new InitSummary(
Guid.Parse("9a5eb53c-3118-4f78-991e-7d2c1af92a14"),
started,
completed,
ImmutableArray.Create(
new InitProviderResult("redhat", "Red Hat", "succeeded", TimeSpan.FromSeconds(12), null),
new InitProviderResult("suse", "SUSE", "failed", TimeSpan.FromSeconds(7), "unreachable")));
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var ok = Assert.IsType<Ok<object>>(result);
Assert.Equal(new[] { "redhat", "suse" }, _orchestrator.LastInitOptions?.Providers);
Assert.True(_orchestrator.LastInitOptions?.Resume);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
Assert.Equal("Initialized 2 provider(s); 1 succeeded, 1 failed.", document.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task RunEndpoint_ReturnsBadRequest_WhenSinceInvalid()
{
var httpContext = CreateHttpContext("vex.admin");
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "not-a-date", null, false);
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var bad = Assert.IsType<BadRequest<object>>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
Assert.Contains("Invalid 'since'", document.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task RunEndpoint_ReturnsBadRequest_WhenWindowInvalid()
{
var httpContext = CreateHttpContext("vex.admin");
var request = new IngestEndpoints.ExcititorIngestRunRequest(Array.Empty<string>(), null, "-01:00:00", false);
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var bad = Assert.IsType<BadRequest<object>>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task RunEndpoint_PassesOptionsToOrchestrator()
{
var httpContext = CreateHttpContext("vex.admin");
var started = DateTimeOffset.Parse("2025-10-20T14:00:00Z");
var completed = started.AddMinutes(5);
_orchestrator.RunFactory = options => new IngestRunSummary(
Guid.Parse("65bbfa25-82fd-41da-8b6b-9d8bb1e2bb5f"),
started,
completed,
ImmutableArray.Create(
new ProviderRunResult(
"redhat",
"succeeded",
12,
42,
started,
completed,
completed - started,
"sha256:abc",
completed.AddHours(-1),
"cp1",
null,
options.Since)));
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "2025-10-19T00:00:00Z", "1.00:00:00", true);
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var ok = Assert.IsType<Ok<object>>(result);
Assert.NotNull(_orchestrator.LastRunOptions);
Assert.Equal(new[] { "redhat" }, _orchestrator.LastRunOptions!.Providers);
Assert.True(_orchestrator.LastRunOptions.Force);
Assert.Equal(TimeSpan.FromDays(1), _orchestrator.LastRunOptions.Window);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
Assert.Equal("cp1", document.RootElement.GetProperty("providers")[0].GetProperty("checkpoint").GetString());
}
[Fact]
public async Task ResumeEndpoint_PassesCheckpointToOrchestrator()
{
var httpContext = CreateHttpContext("vex.admin");
var started = DateTimeOffset.Parse("2025-10-20T16:00:00Z");
var completed = started.AddMinutes(2);
_orchestrator.ResumeFactory = options => new IngestRunSummary(
Guid.Parse("88407f25-4b3f-434d-8f8e-1c7f4925c37b"),
started,
completed,
ImmutableArray.Create(
new ProviderRunResult(
"suse",
"succeeded",
5,
10,
started,
completed,
completed - started,
null,
null,
options.Checkpoint,
null,
DateTimeOffset.UtcNow.AddDays(-1))));
var request = new IngestEndpoints.ExcititorIngestResumeRequest(new[] { "suse" }, "resume-token");
var result = await IngestEndpoints.HandleResumeAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
Assert.IsType<Ok<object>>(result);
Assert.Equal("resume-token", _orchestrator.LastResumeOptions?.Checkpoint);
}
[Fact]
public async Task ReconcileEndpoint_ReturnsBadRequest_WhenMaxAgeInvalid()
{
var httpContext = CreateHttpContext("vex.admin");
var request = new IngestEndpoints.ExcititorReconcileRequest(Array.Empty<string>(), "invalid");
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var bad = Assert.IsType<BadRequest<object>>(result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task ReconcileEndpoint_PassesOptionsAndReturnsSummary()
{
var httpContext = CreateHttpContext("vex.admin");
var started = DateTimeOffset.Parse("2025-10-20T18:00:00Z");
var completed = started.AddMinutes(4);
_orchestrator.ReconcileFactory = options => new ReconcileSummary(
Guid.Parse("a2c2cfe6-c21a-4a62-9db7-2ed2792f4e2d"),
started,
completed,
ImmutableArray.Create(
new ReconcileProviderResult(
"ubuntu",
"succeeded",
"reconciled",
started.AddDays(-2),
started - TimeSpan.FromDays(3),
20,
18,
null)));
var request = new IngestEndpoints.ExcititorReconcileRequest(new[] { "ubuntu" }, "2.00:00:00");
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
var ok = Assert.IsType<Ok<object>>(result);
Assert.Equal(TimeSpan.FromDays(2), _orchestrator.LastReconcileOptions?.MaxAge);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
Assert.Equal("reconciled", document.RootElement.GetProperty("providers")[0].GetProperty("action").GetString());
}
private static DefaultHttpContext CreateHttpContext(params string[] scopes)
{
var context = new DefaultHttpContext
{
RequestServices = new ServiceCollection().BuildServiceProvider(),
Response = { Body = new MemoryStream() }
};
if (scopes.Length > 0)
{
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
claims.Add(new Claim("scope", string.Join(' ', scopes)));
var identity = new ClaimsIdentity(claims, "Test");
context.User = new ClaimsPrincipal(identity);
}
else
{
context.User = new ClaimsPrincipal(new ClaimsIdentity());
}
return context;
}
private sealed class FakeIngestOrchestrator : IVexIngestOrchestrator
{
public IngestInitOptions? LastInitOptions { get; private set; }
public IngestRunOptions? LastRunOptions { get; private set; }
public IngestResumeOptions? LastResumeOptions { get; private set; }
public ReconcileOptions? LastReconcileOptions { get; private set; }
public Func<IngestInitOptions, InitSummary>? InitFactory { get; set; }
public Func<IngestRunOptions, IngestRunSummary>? RunFactory { get; set; }
public Func<IngestResumeOptions, IngestRunSummary>? ResumeFactory { get; set; }
public Func<ReconcileOptions, ReconcileSummary>? ReconcileFactory { get; set; }
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
{
LastInitOptions = options;
return Task.FromResult(InitFactory is null ? CreateDefaultInitSummary() : InitFactory(options));
}
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
{
LastRunOptions = options;
return Task.FromResult(RunFactory is null ? CreateDefaultRunSummary() : RunFactory(options));
}
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
{
LastResumeOptions = options;
return Task.FromResult(ResumeFactory is null ? CreateDefaultRunSummary() : ResumeFactory(options));
}
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
{
LastReconcileOptions = options;
return Task.FromResult(ReconcileFactory is null ? CreateDefaultReconcileSummary() : ReconcileFactory(options));
}
private static InitSummary CreateDefaultInitSummary()
{
var now = DateTimeOffset.UtcNow;
return new InitSummary(Guid.Empty, now, now, ImmutableArray<InitProviderResult>.Empty);
}
private static IngestRunSummary CreateDefaultRunSummary()
{
var now = DateTimeOffset.UtcNow;
return new IngestRunSummary(Guid.Empty, now, now, ImmutableArray<ProviderRunResult>.Empty);
}
private static ReconcileSummary CreateDefaultReconcileSummary()
{
var now = DateTimeOffset.UtcNow;
return new ReconcileSummary(Guid.Empty, now, now, ImmutableArray<ReconcileProviderResult>.Empty);
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class MirrorEndpointsTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public MirrorEndpointsTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
var data = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "mirror-tests",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Id"] = "primary",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:DisplayName"] = "Primary Mirror",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxIndexRequestsPerHour"] = "1000",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxDownloadRequestsPerHour"] = "1000",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Key"] = "consensus",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Format"] = "json",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:vulnId"] = "CVE-2025-0001",
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:productKey"] = "pkg:test/demo",
};
configuration.AddInMemoryCollection(data!);
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.RemoveAll<IVexExportStore>();
services.AddSingleton<IVexExportStore>(provider =>
{
var timeProvider = provider.GetRequiredService<TimeProvider>();
return new FakeExportStore(timeProvider);
});
services.RemoveAll<IVexArtifactStore>();
services.AddSingleton<IVexArtifactStore>(_ => new FakeArtifactStore());
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
services.AddSingleton<StellaOps.Excititor.Attestation.Signing.IVexSigner, FakeSigner>();
services.AddSingleton<StellaOps.Excititor.Policy.IVexPolicyEvaluator, FakePolicyEvaluator>();
});
}
[Fact]
public async Task ListDomains_ReturnsConfiguredDomain()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/excititor/mirror/domains");
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var domains = document.RootElement.GetProperty("domains");
Assert.Equal(1, domains.GetArrayLength());
Assert.Equal("primary", domains[0].GetProperty("id").GetString());
}
[Fact]
public async Task DomainIndex_ReturnsManifestMetadata()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/excititor/mirror/domains/primary/index");
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var exports = document.RootElement.GetProperty("exports");
Assert.Equal(1, exports.GetArrayLength());
var entry = exports[0];
Assert.Equal("consensus", entry.GetProperty("exportKey").GetString());
Assert.Equal("exports/20251019T000000000Z/abcdef", entry.GetProperty("exportId").GetString());
var artifact = entry.GetProperty("artifact");
Assert.Equal("sha256", artifact.GetProperty("algorithm").GetString());
Assert.Equal("deadbeef", artifact.GetProperty("digest").GetString());
}
[Fact]
public async Task Download_ReturnsArtifactContent()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/excititor/mirror/domains/primary/exports/consensus/download");
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal("{\"status\":\"ok\"}", payload);
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class FakeExportStore : IVexExportStore
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _manifests = new();
public FakeExportStore(TimeProvider timeProvider)
{
var filters = new[]
{
new VexQueryFilter("vulnId", "CVE-2025-0001"),
new VexQueryFilter("productKey", "pkg:test/demo"),
};
var query = VexQuery.Create(filters, Enumerable.Empty<VexQuerySort>());
var signature = VexQuerySignature.FromQuery(query);
var createdAt = new DateTimeOffset(2025, 10, 19, 0, 0, 0, TimeSpan.Zero);
var manifest = new VexExportManifest(
"exports/20251019T000000000Z/abcdef",
signature,
VexExportFormat.Json,
createdAt,
new VexContentAddress("sha256", "deadbeef"),
1,
new[] { "primary" },
fromCache: false,
consensusRevision: "rev-1",
attestation: new VexAttestationMetadata("https://stella-ops.org/attestations/vex-export"),
sizeBytes: 16);
_manifests.TryAdd((signature.Value, VexExportFormat.Json), manifest);
// Seed artifact content for download test.
FakeArtifactStore.Seed(manifest.Artifact, "{\"status\":\"ok\"}");
}
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_manifests.TryGetValue((signature.Value, format), out var manifest);
return ValueTask.FromResult(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
}
private sealed class FakeArtifactStore : IVexArtifactStore
{
private static readonly ConcurrentDictionary<VexContentAddress, byte[]> Content = new();
public static void Seed(VexContentAddress contentAddress, string payload)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
Content[contentAddress] = bytes;
}
public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
Content[artifact.ContentAddress] = artifact.Content.ToArray();
return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory://artifact", artifact.Content.Length, artifact.Metadata));
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
Content.TryRemove(contentAddress, out _);
return ValueTask.CompletedTask;
}
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
if (!Content.TryGetValue(contentAddress, out var bytes))
{
return ValueTask.FromResult<Stream?>(null);
}
return ValueTask.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
}
private sealed class FakeSigner : StellaOps.Excititor.Attestation.Signing.IVexSigner
{
public ValueTask<StellaOps.Excititor.Attestation.Signing.VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new StellaOps.Excititor.Attestation.Signing.VexSignedPayload("signature", "key"));
}
private sealed class FakePolicyEvaluator : StellaOps.Excititor.Policy.IVexPolicyEvaluator
{
public string Version => "test";
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
public double GetProviderWeight(VexProvider provider) => 1.0;
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
rejectionReason = null;
return true;
}
}
}

View File

@@ -0,0 +1,375 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class ResolveEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public ResolveEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-resolve-tests");
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-resolve-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
};
config.AddInMemoryCollection(settings!);
},
configureServices: services =>
{
services.AddTestAuthentication();
TestServiceOverrides.Apply(services);
services.AddSingleton<IVexSigner, FakeSigner>();
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
});
}
[Fact]
public async Task ResolveEndpoint_ReturnsBadRequest_WhenInputsMissing()
{
var client = CreateClient("vex.read");
var response = await client.PostAsJsonAsync("/excititor/resolve", new { vulnerabilityIds = new[] { "CVE-2025-0001" } });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ResolveEndpoint_ComputesConsensusAndAttestation()
{
const string vulnerabilityId = "CVE-2025-2222";
const string productKey = "pkg:nuget/StellaOps.Demo@1.0.0";
const string providerId = "redhat";
await SeedProviderAsync(providerId);
await SeedClaimAsync(vulnerabilityId, productKey, providerId);
var client = CreateClient("vex.read");
var request = new ResolveRequest(
new[] { productKey },
null,
new[] { vulnerabilityId },
null);
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<ResolveResponse>();
Assert.NotNull(payload);
Assert.NotNull(payload!.Policy);
var result = Assert.Single(payload.Results);
Assert.Equal(vulnerabilityId, result.VulnerabilityId);
Assert.Equal(productKey, result.ProductKey);
Assert.Equal("not_affected", result.Status);
Assert.NotNull(result.Envelope);
Assert.Equal("signature", result.Envelope!.ContentSignature!.Value);
Assert.Equal("key", result.Envelope.ContentSignature.KeyId);
Assert.NotEqual(default, result.CalculatedAt);
Assert.NotNull(result.Signals);
Assert.True(result.Signals!.Kev);
Assert.NotNull(result.Envelope.AttestationSignature);
Assert.False(string.IsNullOrWhiteSpace(result.Envelope.AttestationEnvelope));
Assert.Equal(payload.Policy.ActiveRevisionId, result.PolicyRevisionId);
Assert.Equal(payload.Policy.Version, result.PolicyVersion);
Assert.Equal(payload.Policy.Digest, result.PolicyDigest);
var decision = Assert.Single(result.Decisions);
Assert.True(decision.Included);
Assert.Equal(providerId, decision.ProviderId);
}
[Fact]
public async Task ResolveEndpoint_ReturnsConflict_WhenPolicyRevisionMismatch()
{
const string vulnerabilityId = "CVE-2025-3333";
const string productKey = "pkg:docker/demo@sha256:abcd";
var client = CreateClient("vex.read");
var request = new ResolveRequest(
new[] { productKey },
null,
new[] { vulnerabilityId },
"rev-0");
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task ResolveEndpoint_ReturnsUnauthorized_WhenMissingToken()
{
var client = CreateClient();
var request = new ResolveRequest(
new[] { "pkg:test/demo" },
null,
new[] { "CVE-2025-0001" },
null);
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task ResolveEndpoint_ReturnsForbidden_WhenScopeMissing()
{
var client = CreateClient("vex.admin");
var request = new ResolveRequest(
new[] { "pkg:test/demo" },
null,
new[] { "CVE-2025-0001" },
null);
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
private async Task SeedProviderAsync(string providerId)
{
await using var scope = _factory.Services.CreateAsyncScope();
var store = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
var provider = new VexProvider(providerId, "Red Hat", VexProviderKind.Distro);
await store.SaveAsync(provider, CancellationToken.None);
}
private async Task SeedClaimAsync(string vulnerabilityId, string productKey, string providerId)
{
await using var scope = _factory.Services.CreateAsyncScope();
var store = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
var observedAt = timeProvider.GetUtcNow();
var claim = new VexClaim(
vulnerabilityId,
providerId,
new VexProduct(productKey, "Demo Component", version: "1.0.0", purl: productKey),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:deadbeef", new Uri("https://example.org/vex/csaf.json")),
observedAt.AddDays(-1),
observedAt,
VexJustification.ProtectedByMitigatingControl,
detail: "Test justification",
confidence: new VexConfidence("high", 0.9, "unit-test"),
signals: new VexSignalSnapshot(
new VexSeveritySignal("cvss:v3.1", 5.5, "medium"),
kev: true,
epss: 0.25));
await store.AppendAsync(new[] { claim }, observedAt, CancellationToken.None);
}
private HttpClient CreateClient(params string[] scopes)
{
var client = _factory.CreateClient();
if (scopes.Length > 0)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", string.Join(' ', scopes));
}
return client;
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class ResolveRequest
{
public ResolveRequest(
IReadOnlyList<string>? productKeys,
IReadOnlyList<string>? purls,
IReadOnlyList<string>? vulnerabilityIds,
string? policyRevisionId)
{
ProductKeys = productKeys;
Purls = purls;
VulnerabilityIds = vulnerabilityIds;
PolicyRevisionId = policyRevisionId;
}
public IReadOnlyList<string>? ProductKeys { get; }
public IReadOnlyList<string>? Purls { get; }
public IReadOnlyList<string>? VulnerabilityIds { get; }
public string? PolicyRevisionId { get; }
}
private sealed class ResolveResponse
{
public required DateTimeOffset ResolvedAt { get; init; }
public required ResolvePolicy Policy { get; init; }
public required List<ResolveResult> Results { get; init; }
}
private sealed class ResolvePolicy
{
public required string ActiveRevisionId { get; init; }
public required string Version { get; init; }
public required string Digest { get; init; }
public string? RequestedRevisionId { get; init; }
}
private sealed class ResolveResult
{
public required string VulnerabilityId { get; init; }
public required string ProductKey { get; init; }
public required string Status { get; init; }
public required DateTimeOffset CalculatedAt { get; init; }
public required List<ResolveSource> Sources { get; init; }
public required List<ResolveConflict> Conflicts { get; init; }
public ResolveSignals? Signals { get; init; }
public string? Summary { get; init; }
public required string PolicyRevisionId { get; init; }
public required string PolicyVersion { get; init; }
public required string PolicyDigest { get; init; }
public required List<ResolveDecision> Decisions { get; init; }
public ResolveEnvelope? Envelope { get; init; }
}
private sealed class ResolveSource
{
public required string ProviderId { get; init; }
}
private sealed class ResolveConflict
{
public string? ProviderId { get; init; }
}
private sealed class ResolveSignals
{
public ResolveSeverity? Severity { get; init; }
public bool? Kev { get; init; }
public double? Epss { get; init; }
}
private sealed class ResolveSeverity
{
public string? Scheme { get; init; }
public double? Score { get; init; }
}
private sealed class ResolveDecision
{
public required string ProviderId { get; init; }
public required bool Included { get; init; }
public string? Reason { get; init; }
}
private sealed class ResolveEnvelope
{
public required ResolveArtifact Artifact { get; init; }
public ResolveSignature? ContentSignature { get; init; }
public ResolveAttestationMetadata? Attestation { get; init; }
public string? AttestationEnvelope { get; init; }
public ResolveSignature? AttestationSignature { get; init; }
}
private sealed class ResolveArtifact
{
public required string Algorithm { get; init; }
public required string Digest { get; init; }
}
private sealed class ResolveSignature
{
public required string Value { get; init; }
public string? KeyId { get; init; }
}
private sealed class ResolveAttestationMetadata
{
public required string PredicateType { get; init; }
public ResolveRekorReference? Rekor { get; init; }
public string? EnvelopeDigest { get; init; }
public DateTimeOffset? SignedAt { get; init; }
}
private sealed class ResolveRekorReference
{
public string? Location { get; init; }
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
{
public string Version => "test";
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
public double GetProviderWeight(VexProvider provider) => 1.0;
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
rejectionReason = null;
return true;
}
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net.Http.Json;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.WebService;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class StatusEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public StatusEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-offline-tests");
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-web-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
};
config.AddInMemoryCollection(settings!);
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddSingleton<IVexSigner, FakeSigner>();
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
});
}
[Fact]
public async Task StatusEndpoint_ReturnsArtifactStores()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/excititor/status");
var raw = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, raw);
var payload = System.Text.Json.JsonSerializer.Deserialize<StatusResponse>(raw);
Assert.NotNull(payload);
Assert.NotEmpty(payload!.ArtifactStores);
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class StatusResponse
{
public string[] ArtifactStores { get; set; } = Array.Empty<string>();
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
{
public string Version => "test";
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
public double GetProviderWeight(VexProvider provider) => 1.0;
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
rejectionReason = null;
return true;
}
}
}

View File

@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EphemeralMongo" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.WebService.Tests;
internal static class TestAuthenticationExtensions
{
public const string SchemeName = "TestBearer";
public static AuthenticationBuilder AddTestAuthentication(this IServiceCollection services)
{
return services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = SchemeName;
options.DefaultChallengeScheme = SchemeName;
}).AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(SchemeName, _ => { });
}
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authorization) || authorization.Count == 0)
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var header = authorization[0];
if (string.IsNullOrWhiteSpace(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid authentication scheme."));
}
var scopeSegment = header.Substring("Bearer ".Length);
var scopes = scopeSegment.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
if (scopes.Length > 0)
{
claims.Add(new Claim("scope", string.Join(' ', scopes)));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}

View File

@@ -0,0 +1,208 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using MongoDB.Driver;
using StellaOps.Excititor.Attestation.Dsse;
namespace StellaOps.Excititor.WebService.Tests;
internal static class TestServiceOverrides
{
public static void Apply(IServiceCollection services)
{
services.RemoveAll<IVexConnector>();
services.RemoveAll<IVexIngestOrchestrator>();
services.RemoveAll<IVexConnectorStateRepository>();
services.RemoveAll<IVexExportCacheService>();
services.RemoveAll<IVexExportDataSource>();
services.RemoveAll<IVexExportStore>();
services.RemoveAll<IVexCacheIndex>();
services.RemoveAll<IVexCacheMaintenance>();
services.RemoveAll<IVexAttestationClient>();
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
services.RemoveAll<IExportEngine>();
services.AddSingleton<IExportEngine, StubExportEngine>();
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
services.AddSingleton<IVexExportStore, StubExportStore>();
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
services.RemoveAll<IHostedService>();
services.AddSingleton<IHostedService, NoopHostedService>();
}
private sealed class StubExportCacheService : IVexExportCacheService
{
public ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
}
private sealed class StubExportEngine : IExportEngine
{
public ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken)
{
var manifest = new VexExportManifest(
exportId: "stub/export",
querySignature: VexQuerySignature.FromQuery(context.Query),
format: context.Format,
createdAt: DateTimeOffset.UtcNow,
artifact: new VexContentAddress("sha256", "stub"),
claimCount: 0,
sourceProviders: Array.Empty<string>());
return ValueTask.FromResult(manifest);
}
}
private sealed class StubExportDataSource : IVexExportDataSource
{
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
return ValueTask.FromResult(new VexExportDataSet(
ImmutableArray<VexConsensus>.Empty,
ImmutableArray<VexClaim>.Empty,
ImmutableArray<string>.Empty));
}
}
private sealed class StubExportStore : IVexExportStore
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _store = new();
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_store.TryGetValue((signature.Value, format), out var manifest);
return ValueTask.FromResult(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_store[(manifest.QuerySignature.Value, manifest.Format)] = manifest;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheIndex : IVexCacheIndex
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexCacheEntry> _entries = new();
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries.TryGetValue((signature.Value, format), out var entry);
return ValueTask.FromResult(entry);
}
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries.TryRemove((signature.Value, format), out _);
return ValueTask.CompletedTask;
}
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries[(entry.QuerySignature.Value, entry.Format)] = entry;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheMaintenance : IVexCacheMaintenance
{
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0);
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0);
}
private sealed class StubAttestationClient : IVexAttestationClient
{
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
{
var envelope = new DsseEnvelope(
Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")),
"application/vnd.in-toto+json",
new[]
{
new DsseSignature("attestation-signature", "attestation-key"),
});
var diagnostics = ImmutableDictionary<string, string>.Empty
.Add("envelope", JsonSerializer.Serialize(envelope));
var metadata = new VexAttestationMetadata(
"stub",
envelopeDigest: VexDsseBuilder.ComputeEnvelopeDigest(envelope),
signedAt: DateTimeOffset.UtcNow);
var response = new VexAttestationResponse(
metadata,
diagnostics);
return ValueTask.FromResult(response);
}
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty);
return ValueTask.FromResult(verification);
}
}
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
{
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
}
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
{
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new InitSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<InitProviderResult>.Empty));
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new ReconcileSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ReconcileProviderResult>.Empty));
}
private sealed class NoopHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace StellaOps.Excititor.WebService.Tests;
internal sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly Action<IConfigurationBuilder>? _configureConfiguration;
private readonly Action<IServiceCollection>? _configureServices;
public TestWebApplicationFactory(
Action<IConfigurationBuilder>? configureConfiguration,
Action<IServiceCollection>? configureServices)
{
_configureConfiguration = configureConfiguration;
_configureServices = configureServices;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Production");
if (_configureConfiguration is not null)
{
builder.ConfigureAppConfiguration((_, config) => _configureConfiguration(config));
}
if (_configureServices is not null)
{
builder.ConfigureServices(services => _configureServices(services));
}
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.UseEnvironment("Production");
builder.UseDefaultServiceProvider(options => options.ValidateScopes = false);
return base.CreateHost(builder);
}
}

View File

@@ -0,0 +1,363 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Aoc;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Plugin;
using Xunit;
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Worker.Tests;
public sealed class DefaultVexProviderRunnerIntegrationTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly MongoClient _client;
public DefaultVexProviderRunnerIntegrationTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
_client = new MongoClient(_runner.ConnectionString);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task RunAsync_LargeBatch_IdempotentAcrossRestart()
{
var specs = CreateDocumentSpecs(count: 48);
var databaseName = $"vex-worker-batch-{Guid.NewGuid():N}";
var (provider, guard, database, connector) = ConfigureIntegrationServices(databaseName, specs);
try
{
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 28, 8, 0, 0, TimeSpan.Zero));
var runner = CreateRunner(provider, time);
var schedule = new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, VexConnectorSettings.Empty);
await runner.RunAsync(schedule, CancellationToken.None);
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var stored = await rawCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
stored.Should().HaveCount(specs.Count);
// Supersedes metadata is preserved for chained documents.
var target = specs[17];
var storedTarget = stored.Single(doc => doc["_id"] == target.Digest);
storedTarget["Metadata"].AsBsonDocument.TryGetValue("aoc.supersedes", out var supersedesValue)
.Should().BeTrue();
supersedesValue!.AsString.Should().Be(target.Metadata["aoc.supersedes"]);
await runner.RunAsync(schedule, CancellationToken.None);
var afterRestart = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
afterRestart.Should().Be(specs.Count);
// Guard invoked for every document across both runs.
guard.Invocations
.GroupBy(doc => doc.Upstream.ContentHash)
.Should().OnlyContain(group => group.Count() == 2);
// Verify provenance still carries supersedes linkage.
var provenance = guard.Invocations
.Where(doc => doc.Upstream.ContentHash == target.Digest)
.Select(doc => doc.Upstream.Provenance["aoc.supersedes"])
.ToImmutableArray();
provenance.Should().HaveCount(2).And.AllBeEquivalentTo(target.Metadata["aoc.supersedes"]);
}
finally
{
await _client.DropDatabaseAsync(databaseName);
await provider.DisposeAsync();
}
}
[Fact]
public async Task RunAsync_WhenGuardFails_RestartCompletesSuccessfully()
{
var specs = CreateDocumentSpecs(count: 24);
var failureDigest = specs[9].Digest;
var databaseName = $"vex-worker-guard-{Guid.NewGuid():N}";
var (provider, guard, database, connector) = ConfigureIntegrationServices(databaseName, specs, failureDigest);
try
{
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 28, 9, 0, 0, TimeSpan.Zero));
var runner = CreateRunner(provider, time);
var schedule = new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(5), TimeSpan.Zero, VexConnectorSettings.Empty);
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => runner.RunAsync(schedule, CancellationToken.None).AsTask());
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
var storedCount = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
storedCount.Should().Be(9); // documents before the failing digest persist
guard.FailDigest = null;
time.Advance(TimeSpan.FromMinutes(10));
await runner.RunAsync(schedule, CancellationToken.None);
var finalCount = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
finalCount.Should().Be(specs.Count);
guard.Invocations.Count(doc => doc.Upstream.ContentHash == failureDigest).Should().Be(2);
}
finally
{
await _client.DropDatabaseAsync(databaseName);
await provider.DisposeAsync();
}
}
private (ServiceProvider Provider, RecordingVexRawWriteGuard Guard, IMongoDatabase Database, BatchingConnector Connector) ConfigureIntegrationServices(
string databaseName,
IReadOnlyList<DocumentSpec> specs,
string? guardFailureDigest = null)
{
var database = _client.GetDatabase(databaseName);
var optionsValue = new VexMongoStorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = databaseName,
DefaultTenant = "tenant-integration",
GridFsInlineThresholdBytes = 64 * 1024,
};
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
var sessionProvider = new DirectSessionProvider(_client);
var guard = new RecordingVexRawWriteGuard { FailDigest = guardFailureDigest };
var rawStore = new MongoVexRawStore(_client, database, options, sessionProvider, guard);
var providerStore = new MongoVexProviderStore(database);
var stateRepository = new MongoVexConnectorStateRepository(database);
var connector = new BatchingConnector("integration:test", specs);
var services = new ServiceCollection();
services.AddSingleton<IVexConnector>(connector);
services.AddSingleton<IVexRawStore>(rawStore);
services.AddSingleton<IVexProviderStore>(providerStore);
services.AddSingleton<IVexConnectorStateRepository>(stateRepository);
services.AddSingleton<IVexClaimStore>(new NoopClaimStore());
services.AddSingleton<IVexNormalizerRouter>(new NoopNormalizerRouter());
services.AddSingleton<IVexSignatureVerifier>(new NoopSignatureVerifier());
return (services.BuildServiceProvider(), guard, database, connector);
}
private static DefaultVexProviderRunner CreateRunner(IServiceProvider services, TimeProvider timeProvider)
{
var options = new VexWorkerOptions
{
Retry =
{
BaseDelay = TimeSpan.FromSeconds(5),
MaxDelay = TimeSpan.FromMinutes(1),
JitterRatio = 0.1,
FailureThreshold = 3,
QuarantineDuration = TimeSpan.FromMinutes(30),
},
};
return new DefaultVexProviderRunner(
services,
new PluginCatalog(),
NullLogger<DefaultVexProviderRunner>.Instance,
timeProvider,
Microsoft.Extensions.Options.Options.Create(options));
}
private static List<DocumentSpec> CreateDocumentSpecs(int count)
{
var specs = new List<DocumentSpec>(capacity: count);
for (var i = 0; i < count; i++)
{
var payload = JsonSerializer.Serialize(new
{
id = i,
title = $"VEX advisory {i}",
supersedes = i == 0 ? null : $"sha256:batch-{i - 1:D4}",
});
var digest = ComputeDigest(payload);
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["source.vendor"] = "integration-vendor";
metadataBuilder["source.connector"] = "integration-connector";
metadataBuilder["aoc.supersedes"] = i == 0 ? string.Empty : $"sha256:batch-{i - 1:D4}";
specs.Add(new DocumentSpec(
ProviderId: "integration-provider",
Format: VexDocumentFormat.Csaf,
SourceUri: new Uri($"https://example.org/vex/{i}.json"),
RetrievedAt: new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero).AddMinutes(i),
Digest: digest,
Payload: payload,
Metadata: metadataBuilder.ToImmutable()));
}
return specs;
}
private static string ComputeDigest(string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
Span<byte> buffer = stackalloc byte[32];
if (SHA256.TryHashData(bytes, buffer, out _))
{
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
var hash = SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private sealed record DocumentSpec(
string ProviderId,
VexDocumentFormat Format,
Uri SourceUri,
DateTimeOffset RetrievedAt,
string Digest,
string Payload,
ImmutableDictionary<string, string> Metadata)
{
public VexRawDocument CreateDocument()
{
var content = Encoding.UTF8.GetBytes(Payload);
return new VexRawDocument(
ProviderId,
Format,
SourceUri,
RetrievedAt,
Digest,
new ReadOnlyMemory<byte>(content),
Metadata);
}
}
private sealed class BatchingConnector : IVexConnector
{
private readonly IReadOnlyList<DocumentSpec> _specs;
public BatchingConnector(string id, IReadOnlyList<DocumentSpec> specs)
{
Id = id;
_specs = specs;
}
public string Id { get; }
public IReadOnlyList<DocumentSpec> Specs => _specs;
public VexProviderKind Kind => VexProviderKind.Vendor;
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public async IAsyncEnumerable<VexRawDocument> FetchAsync(
VexConnectorContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var spec in _specs)
{
var document = spec.CreateDocument();
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
yield return document;
}
}
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class RecordingVexRawWriteGuard : IVexRawWriteGuard
{
private readonly List<RawVexDocumentModel> _invocations = new();
public IReadOnlyList<RawVexDocumentModel> Invocations => _invocations;
public string? FailDigest { get; set; }
public void EnsureValid(RawVexDocumentModel document)
{
_invocations.Add(document);
if (FailDigest is not null && string.Equals(document.Upstream.ContentHash, FailDigest, StringComparison.Ordinal))
{
var violation = AocViolation.Create(
AocViolationCode.SignatureInvalid,
"/upstream/digest",
"Synthetic guard failure.");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
}
private sealed class NoopClaimStore : IVexClaimStore
{
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(Array.Empty<VexClaim>());
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class DirectSessionProvider : IVexMongoSessionProvider
{
private readonly IMongoClient _client;
public DirectSessionProvider(IMongoClient client)
{
_client = client;
}
public async ValueTask<IClientSessionHandle> StartSessionAsync(CancellationToken cancellationToken = default)
{
return await _client.StartSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan delta) => _utcNow += delta;
}
}

View File

@@ -0,0 +1,717 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Plugin;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Aoc;
using Xunit;
using System.Runtime.CompilerServices;
namespace StellaOps.Excititor.Worker.Tests;
public sealed class DefaultVexProviderRunnerTests
{
private static readonly VexConnectorSettings EmptySettings = VexConnectorSettings.Empty;
[Fact]
public async Task RunAsync_Skips_WhenNextEligibleRunInFuture()
{
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 21, 15, 0, 0, TimeSpan.Zero));
var connector = TestConnector.Success("excititor:test");
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
"excititor:test",
LastUpdated: null,
DocumentDigests: ImmutableArray<string>.Empty,
ResumeTokens: ImmutableDictionary<string, string>.Empty,
LastSuccessAt: null,
FailureCount: 1,
NextEligibleRun: time.GetUtcNow().AddHours(1),
LastFailureReason: "previous failure"));
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
options.Retry.MaxDelay = TimeSpan.FromMinutes(30);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
connector.FetchInvoked.Should().BeFalse();
var state = stateRepository.Get("excititor:test");
state.Should().NotBeNull();
state!.FailureCount.Should().Be(1);
state.NextEligibleRun.Should().Be(time.GetUtcNow().AddHours(1));
}
[Fact]
public async Task RunAsync_Success_ResetsFailureCounters()
{
var now = new DateTimeOffset(2025, 10, 21, 16, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var connector = TestConnector.Success("excititor:test");
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
"excititor:test",
LastUpdated: now.AddDays(-1),
DocumentDigests: ImmutableArray<string>.Empty,
ResumeTokens: ImmutableDictionary<string, string>.Empty,
LastSuccessAt: now.AddHours(-4),
FailureCount: 2,
NextEligibleRun: null,
LastFailureReason: "failure"));
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(2);
options.Retry.MaxDelay = TimeSpan.FromMinutes(30);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
connector.FetchInvoked.Should().BeTrue();
var state = stateRepository.Get("excititor:test");
state.Should().NotBeNull();
state!.FailureCount.Should().Be(0);
state.NextEligibleRun.Should().BeNull();
state.LastFailureReason.Should().BeNull();
state.LastSuccessAt.Should().Be(now);
}
[Fact]
public async Task RunAsync_UsesStoredResumeTokens()
{
var now = new DateTimeOffset(2025, 10, 21, 18, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var resumeTokens = ImmutableDictionary<string, string>.Empty
.Add("cursor", "abc123");
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
"excititor:resume",
LastUpdated: now.AddHours(-6),
DocumentDigests: ImmutableArray<string>.Empty,
ResumeTokens: resumeTokens,
LastSuccessAt: now.AddHours(-7),
FailureCount: 0,
NextEligibleRun: null,
LastFailureReason: null));
var connector = TestConnector.SuccessWithCapture("excititor:resume");
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(2);
options.Retry.MaxDelay = TimeSpan.FromMinutes(10);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
connector.LastContext.Should().NotBeNull();
connector.LastContext!.Since.Should().Be(now.AddHours(-6));
connector.LastContext.ResumeTokens.Should().BeEquivalentTo(resumeTokens);
}
[Fact]
public async Task RunAsync_SchedulesRefresh_ForUniqueClaims()
{
var now = new DateTimeOffset(2025, 10, 21, 19, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var rawDocument = new VexRawDocument(
"provider-a",
VexDocumentFormat.Csaf,
new Uri("https://example.org/vex.json"),
now,
"sha256:raw",
ReadOnlyMemory<byte>.Empty,
ImmutableDictionary<string, string>.Empty);
var claimDocument = new VexClaimDocument(
VexDocumentFormat.Csaf,
"sha256:claim",
new Uri("https://example.org/vex.json"));
var primaryProduct = new VexProduct("pkg:test/app", "Test App", componentIdentifiers: new[] { "fingerprint:base" });
var secondaryProduct = new VexProduct("pkg:test/other", "Other App", componentIdentifiers: new[] { "fingerprint:other" });
var claims = new[]
{
new VexClaim("CVE-2025-0001", "provider-a", primaryProduct, VexClaimStatus.Affected, claimDocument, now.AddHours(-3), now.AddHours(-2)),
new VexClaim("CVE-2025-0001", "provider-b", primaryProduct, VexClaimStatus.NotAffected, claimDocument, now.AddHours(-3), now.AddHours(-2)),
new VexClaim("CVE-2025-0002", "provider-a", secondaryProduct, VexClaimStatus.Affected, claimDocument, now.AddHours(-2), now.AddHours(-1)),
};
var connector = TestConnector.WithDocuments("excititor:test", rawDocument);
var stateRepository = new InMemoryStateRepository();
var normalizer = new StubNormalizerRouter(claims);
var services = CreateServiceProvider(connector, stateRepository, normalizer);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
normalizer.CallCount.Should().Be(0);
}
[Fact]
public async Task RunAsync_WhenSignatureVerifierFails_PropagatesException()
{
var now = new DateTimeOffset(2025, 10, 21, 20, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var content = Encoding.UTF8.GetBytes("{\"id\":\"sig\"}");
var digest = ComputeDigest(content);
var rawDocument = new VexRawDocument(
"provider-a",
VexDocumentFormat.Csaf,
new Uri("https://example.org/vex.json"),
now,
digest,
content,
ImmutableDictionary<string, string>.Empty);
var connector = TestConnector.WithDocuments("excititor:test", rawDocument);
var stateRepository = new InMemoryStateRepository();
var failingVerifier = new ThrowingSignatureVerifier();
var rawStore = new NoopRawStore();
var services = CreateServiceProvider(
connector,
stateRepository,
normalizerRouter: null,
signatureVerifier: failingVerifier,
rawStore: rawStore);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
options.Retry.JitterRatio = 0;
});
await Assert.ThrowsAsync<ExcititorAocGuardException>(async () =>
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None).AsTask());
failingVerifier.Invocations.Should().Be(1);
rawStore.StoreCallCount.Should().Be(0);
}
[Fact]
public async Task RunAsync_EnrichesMetadataWithSignatureResult()
{
var now = new DateTimeOffset(2025, 10, 21, 21, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var content = Encoding.UTF8.GetBytes("{\"id\":\"sig\"}");
var digest = ComputeDigest(content);
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.OciAttestation,
new Uri("https://example.org/attest.json"),
now,
digest,
content,
ImmutableDictionary<string, string>.Empty);
var signatureMetadata = new VexSignatureMetadata(
"cosign",
subject: "subject",
issuer: "issuer",
keyId: "kid",
verifiedAt: now,
transparencyLogReference: "rekor://entry");
var signatureVerifier = new RecordingSignatureVerifier(signatureMetadata);
var rawStore = new NoopRawStore();
var connector = TestConnector.WithDocuments("excititor:test", document);
var stateRepository = new InMemoryStateRepository();
var services = CreateServiceProvider(
connector,
stateRepository,
normalizerRouter: null,
signatureVerifier: signatureVerifier,
rawStore: rawStore);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
rawStore.StoreCallCount.Should().Be(1);
rawStore.LastStoredDocument.Should().NotBeNull();
rawStore.LastStoredDocument!.Metadata.Should().ContainKey("vex.signature.type");
rawStore.LastStoredDocument.Metadata["vex.signature.type"].Should().Be("cosign");
rawStore.LastStoredDocument.Metadata["signature.present"].Should().Be("true");
rawStore.LastStoredDocument.Metadata["signature.verified"].Should().Be("true");
signatureVerifier.Invocations.Should().Be(1);
}
[Fact]
public async Task RunAsync_Attestation_StoresVerifierMetadata()
{
var now = new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var document = CreateAttestationRawDocument(now);
var diagnostics = ImmutableDictionary<string, string>.Empty
.Add("verification.issuer", "issuer-from-verifier")
.Add("verification.keyId", "key-from-verifier");
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
var signatureVerifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
attestationVerifier,
time);
var connector = TestConnector.WithDocuments("excititor:test", document);
var stateRepository = new InMemoryStateRepository();
var rawStore = new NoopRawStore();
var services = CreateServiceProvider(
connector,
stateRepository,
normalizerRouter: null,
signatureVerifier: signatureVerifier,
rawStore: rawStore);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
options.Retry.JitterRatio = 0;
});
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
rawStore.StoreCallCount.Should().Be(1);
rawStore.LastStoredDocument.Should().NotBeNull();
var metadata = rawStore.LastStoredDocument!.Metadata;
metadata.Should().ContainKey("vex.signature.type");
metadata["vex.signature.type"].Should().Be("cosign");
metadata["vex.signature.issuer"].Should().Be("issuer-from-verifier");
metadata["vex.signature.keyId"].Should().Be("key-from-verifier");
metadata["signature.present"].Should().Be("true");
metadata["signature.verified"].Should().Be("true");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata["vex.signature.verifiedAt"].Should().Be(now.ToString("O"));
attestationVerifier.Invocations.Should().Be(1);
}
[Fact]
public async Task RunAsync_Failure_AppliesBackoff()
{
var now = new DateTimeOffset(2025, 10, 21, 17, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var connector = TestConnector.Failure("excititor:test", new InvalidOperationException("boom"));
var stateRepository = new InMemoryStateRepository();
stateRepository.Save(new VexConnectorState(
"excititor:test",
LastUpdated: now.AddDays(-2),
DocumentDigests: ImmutableArray<string>.Empty,
ResumeTokens: ImmutableDictionary<string, string>.Empty,
LastSuccessAt: now.AddDays(-1),
FailureCount: 1,
NextEligibleRun: null,
LastFailureReason: null));
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
options.Retry.MaxDelay = TimeSpan.FromMinutes(60);
options.Retry.FailureThreshold = 3;
options.Retry.QuarantineDuration = TimeSpan.FromHours(12);
options.Retry.JitterRatio = 0;
});
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None).AsTask());
var state = stateRepository.Get("excititor:test");
state.Should().NotBeNull();
state!.FailureCount.Should().Be(2);
state.LastFailureReason.Should().Be("boom");
state.NextEligibleRun.Should().Be(now + TimeSpan.FromMinutes(10));
}
private static ServiceProvider CreateServiceProvider(
IVexConnector connector,
InMemoryStateRepository stateRepository,
IVexNormalizerRouter? normalizerRouter = null,
IVexSignatureVerifier? signatureVerifier = null,
NoopRawStore? rawStore = null)
{
var services = new ServiceCollection();
services.AddSingleton(connector);
rawStore ??= new NoopRawStore();
services.AddSingleton(rawStore);
services.AddSingleton<IVexRawStore>(sp => rawStore);
services.AddSingleton<IVexClaimStore>(new NoopClaimStore());
services.AddSingleton<IVexProviderStore>(new NoopProviderStore());
services.AddSingleton<IVexConnectorStateRepository>(stateRepository);
services.AddSingleton<IVexNormalizerRouter>(normalizerRouter ?? new NoopNormalizerRouter());
services.AddSingleton<IVexSignatureVerifier>(signatureVerifier ?? new NoopSignatureVerifier());
return services.BuildServiceProvider();
}
private static DefaultVexProviderRunner CreateRunner(
IServiceProvider serviceProvider,
TimeProvider timeProvider,
Action<VexWorkerOptions> configure)
{
var options = new VexWorkerOptions();
configure(options);
return new DefaultVexProviderRunner(
serviceProvider,
new PluginCatalog(),
NullLogger<DefaultVexProviderRunner>.Instance,
timeProvider,
Microsoft.Extensions.Options.Options.Create(options));
}
private sealed class FixedTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan delta) => _utcNow += delta;
}
private sealed class NoopRawStore : IVexRawStore
{
public int StoreCallCount { get; private set; }
public VexRawDocument? LastStoredDocument { get; private set; }
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
StoreCallCount++;
LastStoredDocument = document;
return ValueTask.CompletedTask;
}
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken, IClientSessionHandle? session)
{
StoreCallCount++;
LastStoredDocument = document;
return ValueTask.CompletedTask;
}
public ValueTask<VexRawDocument?> FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<VexRawDocument?>(null);
}
private sealed class NoopClaimStore : IVexClaimStore
{
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(Array.Empty<VexClaim>());
}
private sealed class NoopProviderStore : IVexProviderStore
{
private readonly ConcurrentDictionary<string, VexProvider> _providers = new(StringComparer.Ordinal);
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_providers.TryGetValue(id, out var provider);
return ValueTask.FromResult<VexProvider?>(provider);
}
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(_providers.Values.ToList());
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_providers[provider.Id] = provider;
return ValueTask.CompletedTask;
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class StubNormalizerRouter : IVexNormalizerRouter
{
private readonly ImmutableArray<VexClaim> _claims;
public StubNormalizerRouter(IEnumerable<VexClaim> claims)
{
_claims = claims.ToImmutableArray();
}
public int CallCount { get; private set; }
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
CallCount++;
return ValueTask.FromResult(new VexClaimBatch(document, _claims, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class InMemoryStateRepository : IVexConnectorStateRepository
{
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
public VexConnectorState? Get(string connectorId)
=> _states.TryGetValue(connectorId, out var state) ? state : null;
public void Save(VexConnectorState state)
=> _states[state.ConnectorId] = state;
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(Get(connectorId));
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Save(state);
return ValueTask.CompletedTask;
}
}
private sealed class TestConnector : IVexConnector
{
private readonly Func<VexConnectorContext, CancellationToken, IAsyncEnumerable<VexRawDocument>> _fetch;
private readonly Exception? _normalizeException;
private readonly List<VexConnectorContext>? _capturedContexts;
private TestConnector(string id, Func<VexConnectorContext, CancellationToken, IAsyncEnumerable<VexRawDocument>> fetch, Exception? normalizeException = null, List<VexConnectorContext>? capturedContexts = null)
{
Id = id;
_fetch = fetch;
_normalizeException = normalizeException;
_capturedContexts = capturedContexts;
}
public static TestConnector Success(string id) => new(id, (_, _) => AsyncEnumerable.Empty<VexRawDocument>());
public static TestConnector SuccessWithCapture(string id)
{
var contexts = new List<VexConnectorContext>();
return new TestConnector(id, (_, _) => AsyncEnumerable.Empty<VexRawDocument>(), capturedContexts: contexts);
}
public static TestConnector WithDocuments(string id, params VexRawDocument[] documents)
{
return new TestConnector(id, (context, cancellationToken) => StreamAsync(context, documents, cancellationToken));
}
private static async IAsyncEnumerable<VexRawDocument> StreamAsync(
VexConnectorContext context,
IReadOnlyList<VexRawDocument> documents,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var document in documents)
{
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
yield return document;
}
}
public static TestConnector Failure(string id, Exception exception)
{
return new TestConnector(id, (_, _) => new ThrowingAsyncEnumerable(exception));
}
public string Id { get; }
public VexProviderKind Kind => VexProviderKind.Vendor;
public bool ValidateInvoked { get; private set; }
public bool FetchInvoked { get; private set; }
public VexConnectorContext? LastContext => _capturedContexts is { Count: > 0 } ? _capturedContexts[^1] : null;
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
ValidateInvoked = true;
return ValueTask.CompletedTask;
}
public IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken)
{
FetchInvoked = true;
_capturedContexts?.Add(context);
return _fetch(context, cancellationToken);
}
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
if (_normalizeException is not null)
{
throw _normalizeException;
}
return ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class ThrowingAsyncEnumerable : IAsyncEnumerable<VexRawDocument>, IAsyncEnumerator<VexRawDocument>
{
private readonly Exception _exception;
public ThrowingAsyncEnumerable(Exception exception) => _exception = exception;
public IAsyncEnumerator<VexRawDocument> GetAsyncEnumerator(CancellationToken cancellationToken = default) => this;
public ValueTask<bool> MoveNextAsync() => ValueTask.FromException<bool>(_exception);
public VexRawDocument Current => throw new InvalidOperationException();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class ThrowingSignatureVerifier : IVexSignatureVerifier
{
public int Invocations { get; private set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Invocations++;
var violation = AocViolation.Create(
AocViolationCode.SignatureInvalid,
"/upstream/signature",
"Synthetic verifier failure.");
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
private sealed class RecordingSignatureVerifier : IVexSignatureVerifier
{
private readonly VexSignatureMetadata? _result;
public RecordingSignatureVerifier(VexSignatureMetadata? result) => _result = result;
public int Invocations { get; private set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(_result);
}
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly ImmutableDictionary<string, string> _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
{
_isValid = isValid;
_diagnostics = diagnostics;
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private static VexRawDocument CreateAttestationRawDocument(DateTimeOffset observedAt)
{
var predicate = new VexAttestationPredicate(
"export-id",
"query-signature",
"sha256",
"abcd1234",
VexExportFormat.Json,
observedAt,
new[] { "provider-a" },
ImmutableDictionary<string, string>.Empty);
var statement = new VexInTotoStatement(
VexInTotoStatement.InTotoType,
"https://stella-ops.org/attestations/vex-export",
new[] { new VexInTotoSubject("export-id", new Dictionary<string, string> { { "sha256", "abcd1234" } }) },
predicate);
var serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, serializerOptions);
var envelope = new DsseEnvelope(
Convert.ToBase64String(payloadBytes),
"application/vnd.in-toto+json",
new[] { new DsseSignature("deadbeef", "sig-key") });
var envelopeJson = JsonSerializer.Serialize(
envelope,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
var contentBytes = Encoding.UTF8.GetBytes(envelopeJson);
return new VexRawDocument(
"provider-a",
VexDocumentFormat.OciAttestation,
new Uri("https://example.org/vex-attestation.json"),
observedAt,
ComputeDigest(contentBytes),
contentBytes,
ImmutableDictionary<string, string>.Empty);
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> buffer = stackalloc byte[32];
if (!SHA256.TryHashData(content, buffer, out _))
{
var hash = SHA256.HashData(content.ToArray());
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,229 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Aoc;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Worker.Signature;
using Xunit;
namespace StellaOps.Excititor.Worker.Tests.Signature;
public sealed class WorkerSignatureVerifierTests
{
[Fact]
public async Task VerifyAsync_ReturnsMetadata_WhenSignatureHintsPresent()
{
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
var digest = ComputeDigest(content);
var metadata = ImmutableDictionary<string, string>.Empty
.Add("tenant", "tenant-a")
.Add("vex.signature.type", "cosign")
.Add("vex.signature.subject", "subject")
.Add("vex.signature.issuer", "issuer")
.Add("vex.signature.keyId", "kid")
.Add("vex.signature.verifiedAt", DateTimeOffset.UtcNow.ToString("O"))
.Add("vex.signature.transparencyLogReference", "rekor://entry");
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.Csaf,
new Uri("https://example.org/vex.json"),
DateTimeOffset.UtcNow,
digest,
content,
metadata);
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance);
var result = await verifier.VerifyAsync(document, CancellationToken.None);
result.Should().NotBeNull();
result!.Type.Should().Be("cosign");
result.Subject.Should().Be("subject");
result.Issuer.Should().Be("issuer");
result.KeyId.Should().Be("kid");
result.TransparencyLogReference.Should().Be("rekor://entry");
}
[Fact]
public async Task VerifyAsync_Throws_WhenChecksumMismatch()
{
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
var metadata = ImmutableDictionary<string, string>.Empty;
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.CycloneDx,
new Uri("https://example.org/vex.json"),
DateTimeOffset.UtcNow,
"sha256:deadbeef",
content,
metadata);
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance);
var exception = await Assert.ThrowsAsync<ExcititorAocGuardException>(() => verifier.VerifyAsync(document, CancellationToken.None).AsTask());
exception.PrimaryErrorCode.Should().Be("ERR_AOC_005");
}
[Fact]
public async Task VerifyAsync_Attestation_UsesVerifier()
{
var now = DateTimeOffset.UtcNow;
var (document, metadata) = CreateAttestationDocument(now, subject: "export-1", includeRekor: true);
var attestationVerifier = new StubAttestationVerifier(true);
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance, attestationVerifier, TimeProvider.System);
var result = await verifier.VerifyAsync(document with { Metadata = metadata }, CancellationToken.None);
result.Should().NotBeNull();
result!.Type.Should().Be("cosign");
result.Subject.Should().Be("export-1");
attestationVerifier.Invocations.Should().Be(1);
}
[Fact]
public async Task VerifyAsync_AttestationThrows_WhenVerifierInvalid()
{
var now = DateTimeOffset.UtcNow;
var (document, metadata) = CreateAttestationDocument(now, subject: "export-2", includeRekor: true);
var attestationVerifier = new StubAttestationVerifier(false);
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance, attestationVerifier, TimeProvider.System);
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => verifier.VerifyAsync(document with { Metadata = metadata }, CancellationToken.None).AsTask());
attestationVerifier.Invocations.Should().Be(1);
}
[Fact]
public async Task VerifyAsync_Attestation_UsesDiagnosticsWhenMetadataMissing()
{
var now = new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero);
var (document, _) = CreateAttestationDocument(now, subject: "export-3", includeRekor: false);
var diagnostics = ImmutableDictionary<string, string>.Empty
.Add("verification.issuer", "issuer-from-attestation")
.Add("verification.keyId", "kid-from-attestation");
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
var verifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
attestationVerifier,
new FixedTimeProvider(now));
var result = await verifier.VerifyAsync(document, CancellationToken.None);
result.Should().NotBeNull();
result!.Issuer.Should().Be("issuer-from-attestation");
result.KeyId.Should().Be("kid-from-attestation");
result.TransparencyLogReference.Should().BeNull();
result.VerifiedAt.Should().Be(now);
attestationVerifier.Invocations.Should().Be(1);
}
private static string ComputeDigest(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
return SHA256.TryHashData(payload, buffer, out _)
? "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant()
: "sha256:" + Convert.ToHexString(SHA256.HashData(payload.ToArray())).ToLowerInvariant();
}
private static (VexRawDocument Document, ImmutableDictionary<string, string> Metadata) CreateAttestationDocument(DateTimeOffset createdAt, string subject, bool includeRekor)
{
var predicate = new VexAttestationPredicate(
subject,
"query=signature",
"sha256",
"abcd1234",
VexExportFormat.Json,
createdAt,
new[] { "provider-a" },
ImmutableDictionary<string, string>.Empty);
var statement = new VexInTotoStatement(
VexInTotoStatement.InTotoType,
"https://stella-ops.org/attestations/vex-export",
new[] { new VexInTotoSubject(subject, new Dictionary<string, string> { { "sha256", "abcd1234" } }) },
predicate);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
});
var envelope = new DsseEnvelope(
Convert.ToBase64String(payloadBytes),
"application/vnd.in-toto+json",
new[] { new DsseSignature("deadbeef", "key-1") });
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
var contentBytes = Encoding.UTF8.GetBytes(envelopeJson);
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["tenant"] = "tenant-a";
metadataBuilder["vex.signature.type"] = "cosign";
metadataBuilder["vex.signature.verifiedAt"] = createdAt.ToString("O");
if (includeRekor)
{
metadataBuilder["vex.signature.transparencyLogReference"] = "rekor://entry/123";
}
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.OciAttestation,
new Uri("https://example.org/attestation.json"),
createdAt,
ComputeDigest(contentBytes),
contentBytes,
ImmutableDictionary<string, string>.Empty);
return (document, metadataBuilder.ToImmutable());
}
private sealed class StubAttestationVerifier : IVexAttestationVerifier
{
private readonly bool _isValid;
private readonly ImmutableDictionary<string, string> _diagnostics;
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string>? diagnostics = null)
{
_isValid = isValid;
_diagnostics = diagnostics ?? ImmutableDictionary<string, string>.Empty;
}
public int Invocations { get; private set; }
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
Invocations++;
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,30 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Excititor.Worker/StellaOps.Excititor.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,106 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using Xunit;
namespace StellaOps.Excititor.Worker.Tests;
public sealed class VexWorkerOptionsTests
{
[Fact]
public void ResolveSchedules_UsesDefaultIntervalWhenNotSpecified()
{
var options = new VexWorkerOptions
{
DefaultInterval = TimeSpan.FromMinutes(30),
OfflineInterval = TimeSpan.FromHours(6),
OfflineMode = false,
};
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:redhat" });
var schedules = options.ResolveSchedules();
schedules.Should().ContainSingle();
schedules[0].Interval.Should().Be(TimeSpan.FromMinutes(30));
schedules[0].Settings.Should().Be(VexConnectorSettings.Empty);
}
[Fact]
public void ResolveSchedules_HonorsOfflineInterval()
{
var options = new VexWorkerOptions
{
DefaultInterval = TimeSpan.FromMinutes(30),
OfflineInterval = TimeSpan.FromHours(8),
OfflineMode = true,
};
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:offline" });
var schedules = options.ResolveSchedules();
schedules.Should().ContainSingle();
schedules[0].Interval.Should().Be(TimeSpan.FromHours(8));
}
[Fact]
public void ResolveSchedules_SkipsDisabledProviders()
{
var options = new VexWorkerOptions();
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:enabled" });
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:disabled", Enabled = false });
var schedules = options.ResolveSchedules();
schedules.Should().HaveCount(1);
schedules[0].ProviderId.Should().Be("excititor:enabled");
}
[Fact]
public void ResolveSchedules_UsesProviderIntervalOverride()
{
var options = new VexWorkerOptions
{
DefaultInterval = TimeSpan.FromMinutes(15),
};
options.Providers.Add(new VexWorkerProviderOptions
{
ProviderId = "excititor:custom",
Interval = TimeSpan.FromMinutes(5),
InitialDelay = TimeSpan.FromSeconds(10),
});
var schedules = options.ResolveSchedules();
schedules.Should().ContainSingle();
schedules[0].Interval.Should().Be(TimeSpan.FromMinutes(5));
schedules[0].InitialDelay.Should().Be(TimeSpan.FromSeconds(10));
}
[Fact]
public void RefreshOptions_DefaultsAlignWithExpectedValues()
{
var options = new VexWorkerRefreshOptions();
options.Enabled.Should().BeTrue();
options.ConsensusTtl.Should().Be(TimeSpan.FromHours(2));
options.Damper.ResolveDuration(0.95).Should().Be(TimeSpan.FromHours(24));
options.Damper.ResolveDuration(0.6).Should().Be(TimeSpan.FromHours(36));
}
[Fact]
public void DamperOptions_ClampDurationWithinBounds()
{
var options = new VexStabilityDamperOptions
{
Minimum = TimeSpan.FromHours(10),
Maximum = TimeSpan.FromHours(20),
DefaultDuration = TimeSpan.FromHours(30),
};
options.Rules.Clear();
options.Rules.Add(new VexStabilityDamperRule { MinWeight = 0.8, Duration = TimeSpan.FromHours(40) });
options.ClampDuration(TimeSpan.FromHours(5)).Should().Be(TimeSpan.FromHours(10));
options.ResolveDuration(0.85).Should().Be(TimeSpan.FromHours(20));
}
}