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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user