up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,38 +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);
}
}
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

@@ -1,83 +1,83 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
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());
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 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"));
}
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<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);
}

View File

@@ -9,13 +9,13 @@ 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();
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
@@ -30,7 +30,7 @@ public sealed class VexAttestationVerifierTests : IDisposable
Assert.Equal("valid", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
@@ -52,7 +52,7 @@ public sealed class VexAttestationVerifierTests : IDisposable
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
Assert.Equal("envelope_digest_mismatch", verification.Diagnostics.FailureReason);
}
[Fact]
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
{
@@ -247,17 +247,17 @@ public sealed class VexAttestationVerifierTests : IDisposable
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();
=> 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

@@ -1,52 +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));
}
}
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

@@ -1,149 +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));
}
}
}
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

@@ -1,176 +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());
}
}
}
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

@@ -1,76 +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();
}
}
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

@@ -1,83 +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();
}
}
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

@@ -1,312 +1,312 @@
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 System.IO.Abstractions.TestingHelpers;
using Xunit;
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)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
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;
}
}
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 System.IO.Abstractions.TestingHelpers;
using Xunit;
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)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
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

@@ -1,205 +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"),
};
}
}
}
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

@@ -1,235 +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);
}
}
}
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

@@ -1,138 +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));
}
}
}
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

@@ -1,178 +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));
}
}
}
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

@@ -1,172 +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"),
};
}
}
}
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

@@ -1,68 +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);
}
}
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

@@ -1,307 +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);
}
}
}
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

@@ -1,127 +1,127 @@
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);
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);
@@ -146,5 +146,5 @@ public sealed class VexCanonicalJsonSerializerTests
Assert.True(index > lastIndex, $"Token {token} appeared out of order.");
lastIndex = index;
}
}
}
}
}

View File

@@ -1,227 +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);
}
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

@@ -1,130 +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");
}
}
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

@@ -1,169 +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/modules/excititor/architecture.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()
{
}
}
}
}
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/modules/excititor/architecture.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

@@ -1,59 +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);
}
}
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

@@ -1,35 +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));
}
}
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

@@ -1,33 +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));
}
}
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

@@ -1,59 +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());
}
}
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

@@ -1,95 +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);
}
}
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

@@ -1,73 +1,73 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CSAF;
namespace StellaOps.Excititor.Formats.CSAF.Tests;
public sealed class CsafExporterTests
{
[Fact]
public async Task SerializeAsync_WritesDeterministicCsafDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc1", new Uri("https://example.com/csaf/advisory1.json")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Impact on Example App 1.0.0"),
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc2", new Uri("https://example.com/csaf/advisory2.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"ADVISORY-1",
"vendor:example",
new VexProduct("pkg:example/lib@2.0.0", "Example Lib", "2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc3", new Uri("https://example.com/csaf/advisory3.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: null));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 13, 0, 0, 0, TimeSpan.Zero));
var exporter = new CsafExporter();
var digest = exporter.Digest(request);
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
digest.Should().NotBeNull();
digest.Should().Be(result.Digest);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("tracking").GetProperty("id").GetString()!.Should().StartWith("stellaops:csaf");
root.GetProperty("product_tree").GetProperty("full_product_names").GetArrayLength().Should().Be(2);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(2);
var metadata = root.GetProperty("metadata");
metadata.GetProperty("query_signature").GetString().Should().NotBeNull();
metadata.GetProperty("diagnostics").EnumerateObject().Select(p => p.Name).Should().Contain("policy.justification_missing");
result.Metadata.Should().ContainKey("csaf.vulnerabilityCount");
result.Metadata["csaf.productCount"].Should().Be("2");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CSAF;
namespace StellaOps.Excititor.Formats.CSAF.Tests;
public sealed class CsafExporterTests
{
[Fact]
public async Task SerializeAsync_WritesDeterministicCsafDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc1", new Uri("https://example.com/csaf/advisory1.json")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Impact on Example App 1.0.0"),
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc2", new Uri("https://example.com/csaf/advisory2.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"ADVISORY-1",
"vendor:example",
new VexProduct("pkg:example/lib@2.0.0", "Example Lib", "2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc3", new Uri("https://example.com/csaf/advisory3.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: null));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 13, 0, 0, 0, TimeSpan.Zero));
var exporter = new CsafExporter();
var digest = exporter.Digest(request);
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
digest.Should().NotBeNull();
digest.Should().Be(result.Digest);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("tracking").GetProperty("id").GetString()!.Should().StartWith("stellaops:csaf");
root.GetProperty("product_tree").GetProperty("full_product_names").GetArrayLength().Should().Be(2);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(2);
var metadata = root.GetProperty("metadata");
metadata.GetProperty("query_signature").GetString().Should().NotBeNull();
metadata.GetProperty("diagnostics").EnumerateObject().Select(p => p.Name).Should().Contain("policy.justification_missing");
result.Metadata.Should().ContainKey("csaf.vulnerabilityCount");
result.Metadata["csaf.productCount"].Should().Be("2");
}
}

View File

@@ -1,132 +1,132 @@
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");
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");
}
[Fact]

View File

@@ -1,37 +1,37 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxComponentReconcilerTests
{
[Fact]
public void Reconcile_AssignsBomRefsAndDiagnostics()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-7000",
"vendor:one",
new VexProduct("pkg:demo/component@1.0.0", "Demo Component", "1.0.0", "pkg:demo/component@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/vex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow),
new VexClaim(
"CVE-2025-7000",
"vendor:two",
new VexProduct("component-key", "Component Key"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/vex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = CycloneDxComponentReconciler.Reconcile(claims);
result.Components.Should().HaveCount(2);
result.ComponentRefs.Should().ContainKey(("CVE-2025-7000", "component-key"));
result.Diagnostics.Keys.Should().Contain("missing_purl");
}
}
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxComponentReconcilerTests
{
[Fact]
public void Reconcile_AssignsBomRefsAndDiagnostics()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-7000",
"vendor:one",
new VexProduct("pkg:demo/component@1.0.0", "Demo Component", "1.0.0", "pkg:demo/component@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/vex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow),
new VexClaim(
"CVE-2025-7000",
"vendor:two",
new VexProduct("component-key", "Component Key"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/vex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = CycloneDxComponentReconciler.Reconcile(claims);
result.Components.Should().HaveCount(2);
result.ComponentRefs.Should().ContainKey(("CVE-2025-7000", "component-key"));
result.Diagnostics.Keys.Should().Contain("missing_purl");
}
}

View File

@@ -1,47 +1,47 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxExporterTests
{
[Fact]
public async Task SerializeAsync_WritesCycloneDxVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-6000",
"vendor:demo",
new VexProduct("pkg:demo/component@1.2.3", "Demo Component", "1.2.3", "pkg:demo/component@1.2.3"),
VexClaimStatus.Fixed,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Issue resolved in 1.2.3"));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new CycloneDxExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxExporterTests
{
[Fact]
public async Task SerializeAsync_WritesCycloneDxVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-6000",
"vendor:demo",
new VexProduct("pkg:demo/component@1.2.3", "Demo Component", "1.2.3", "pkg:demo/component@1.2.3"),
VexClaimStatus.Fixed,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Issue resolved in 1.2.3"));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new CycloneDxExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}

View File

@@ -1,93 +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");
}
}
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

@@ -1,49 +1,49 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexExporterTests
{
[Fact]
public async Task SerializeAsync_ProducesCanonicalOpenVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-5000",
"vendor:alpha",
new VexProduct("pkg:alpha/app@2.0.0", "Alpha App", "2.0.0", "pkg:alpha/app@2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/alpha")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent,
detail: "Component not shipped."));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new OpenVexExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("author").GetString().Should().Be("StellaOps Excititor");
root.GetProperty("statements").GetArrayLength().Should().Be(1);
var statement = root.GetProperty("statements")[0];
statement.GetProperty("status").GetString().Should().Be("not_affected");
statement.GetProperty("products")[0].GetProperty("id").GetString().Should().Be("pkg:alpha/app@2.0.0");
result.Metadata.Should().ContainKey("openvex.statementCount");
result.Metadata["openvex.statementCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexExporterTests
{
[Fact]
public async Task SerializeAsync_ProducesCanonicalOpenVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-5000",
"vendor:alpha",
new VexProduct("pkg:alpha/app@2.0.0", "Alpha App", "2.0.0", "pkg:alpha/app@2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/alpha")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent,
detail: "Component not shipped."));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new OpenVexExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("author").GetString().Should().Be("StellaOps Excititor");
root.GetProperty("statements").GetArrayLength().Should().Be(1);
var statement = root.GetProperty("statements")[0];
statement.GetProperty("status").GetString().Should().Be("not_affected");
statement.GetProperty("products")[0].GetProperty("id").GetString().Should().Be("pkg:alpha/app@2.0.0");
result.Metadata.Should().ContainKey("openvex.statementCount");
result.Metadata["openvex.statementCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}

View File

@@ -1,87 +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");
}
}
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

@@ -1,39 +1,39 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexStatementMergerTests
{
[Fact]
public void Merge_DetectsConflictsAndSelectsCanonicalStatus()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-4000",
"vendor:one",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"CVE-2025-4000",
"vendor:two",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc2", new Uri("https://example.com/openvex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = OpenVexStatementMerger.Merge(claims);
result.Statements.Should().HaveCount(1);
var statement = result.Statements[0];
statement.Status.Should().Be(VexClaimStatus.Affected);
result.Diagnostics.Should().ContainKey("openvex.status_conflict");
}
}
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexStatementMergerTests
{
[Fact]
public void Merge_DetectsConflictsAndSelectsCanonicalStatus()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-4000",
"vendor:one",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"CVE-2025-4000",
"vendor:two",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc2", new Uri("https://example.com/openvex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = OpenVexStatementMerger.Merge(claims);
result.Statements.Should().HaveCount(1);
var statement = result.Statements[0];
statement.Status.Should().Be(VexClaimStatus.Affected);
result.Diagnostics.Should().ContainKey("openvex.status_conflict");
}
}

View File

@@ -1,102 +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()
{
}
}
}
}
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

@@ -1,274 +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);
}
}
}
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

@@ -1,364 +1,364 @@
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 StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class ResolveEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
public ResolveEndpointTests()
{
_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:DefaultTenant"] = "tests",
["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();
}
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;
}
}
}
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 StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class ResolveEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
public ResolveEndpointTests()
{
_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:DefaultTenant"] = "tests",
["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();
}
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

@@ -1,90 +1,90 @@
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 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;
public StatusEndpointTests()
{
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-offline-tests");
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Postgres:Excititor:ConnectionString"] = "Host=localhost;Username=postgres;Password=postgres;Database=excititor_tests",
["Postgres:Excititor:SchemaName"] = "vex",
["Excititor:Storage:InlineThresholdBytes"] = "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();
}
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;
}
}
}
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 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;
public StatusEndpointTests()
{
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-offline-tests");
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Postgres:Excititor:ConnectionString"] = "Host=localhost;Username=postgres;Password=postgres;Database=excititor_tests",
["Postgres:Excititor:SchemaName"] = "vex",
["Excititor:Storage:InlineThresholdBytes"] = "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();
}
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

@@ -1,61 +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));
}
}
}
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

@@ -1,12 +1,12 @@
using System;
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 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;
@@ -15,130 +15,130 @@ using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.IssuerDirectory.Client;
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);
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,
issuerDirectoryClient: StubIssuerDirectoryClient.DefaultFor("tenant-a", "issuer-a", "kid"));
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 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,
issuerDirectoryClient: StubIssuerDirectoryClient.Empty());
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 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,
StubIssuerDirectoryClient.Empty());
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 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,
StubIssuerDirectoryClient.Empty());
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");
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),
StubIssuerDirectoryClient.DefaultFor("tenant-a", "issuer-from-attestation", "kid-from-attestation"));
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");
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);
@@ -185,67 +185,67 @@ public sealed class WorkerSignatureVerifierTests
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());
}
: "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;
@@ -354,10 +354,10 @@ public sealed class WorkerSignatureVerifierTests
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -1,77 +1,77 @@
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]
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();
};
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));