Add unit tests for RancherHubConnector and various exporters
- Implemented tests for RancherHubConnector to validate fetching documents, handling errors, and managing state. - Added tests for CsafExporter to ensure deterministic serialization of CSAF documents. - Created tests for CycloneDX exporters and reconciler to verify correct handling of VEX claims and output structure. - Developed OpenVEX exporter tests to confirm the generation of canonical OpenVEX documents and statement merging logic. - Introduced Rust file caching and license scanning functionality, including a cache key structure and hash computation. - Added sample Cargo.toml and LICENSE files for testing Rust license scanning functionality.
This commit is contained in:
@@ -6,9 +6,14 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -67,6 +67,45 @@ public sealed class VexAttestationVerifierTests : IDisposable
|
||||
Assert.Equal("offline", verification.Diagnostics["rekor.state"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyRequiredAndMissing()
|
||||
{
|
||||
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
|
||||
var verifier = CreateVerifier(options =>
|
||||
{
|
||||
options.RequireTransparencyLog = true;
|
||||
options.AllowOfflineTransparency = false;
|
||||
});
|
||||
|
||||
var verification = await verifier.VerifyAsync(
|
||||
new VexAttestationVerificationRequest(request, metadata, envelope),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(verification.IsValid);
|
||||
Assert.Equal("missing", verification.Diagnostics["rekor.state"]);
|
||||
Assert.Equal("invalid", verification.Diagnostics["result"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
|
||||
{
|
||||
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
|
||||
var transparency = new ThrowingTransparencyLogClient();
|
||||
var verifier = CreateVerifier(options =>
|
||||
{
|
||||
options.RequireTransparencyLog = true;
|
||||
options.AllowOfflineTransparency = false;
|
||||
}, transparency);
|
||||
|
||||
var verification = await verifier.VerifyAsync(
|
||||
new VexAttestationVerificationRequest(request, metadata, envelope),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(verification.IsValid);
|
||||
Assert.Equal("unreachable", verification.Diagnostics["rekor.state"]);
|
||||
Assert.Equal("invalid", verification.Diagnostics["result"]);
|
||||
}
|
||||
|
||||
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false)
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
|
||||
@@ -107,9 +107,124 @@ public sealed class CiscoCsafConnectorTests
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
}
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EmitsTrustMetadataAndUpsertsProvider()
|
||||
{
|
||||
var metadataResponse = """
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco",
|
||||
"category": "vendor",
|
||||
"contact_details": { "id": "excititor:cisco" }
|
||||
}
|
||||
},
|
||||
"trust": {
|
||||
"weight": 0.75,
|
||||
"cosign": {
|
||||
"issuer": "https://issuer.example.com",
|
||||
"identity_pattern": "https://sig.example.com/*"
|
||||
},
|
||||
"pgp_fingerprints": [
|
||||
"0123456789ABCDEF",
|
||||
"FEDCBA9876543210"
|
||||
]
|
||||
},
|
||||
"distributions": {
|
||||
"directories": [ "https://api.cisco.test/csaf/" ]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var responses = new Dictionary<Uri, Queue<HttpResponseMessage>>
|
||||
{
|
||||
[new Uri("https://api.cisco.test/.well-known/csaf/provider-metadata.json")] = QueueResponses(metadataResponse),
|
||||
[new Uri("https://api.cisco.test/csaf/index.json")] = QueueResponses("""
|
||||
{
|
||||
"advisories": [
|
||||
{
|
||||
"id": "cisco-sa-2025",
|
||||
"url": "https://api.cisco.test/csaf/cisco-sa-2025.json",
|
||||
"published": "2025-10-01T00:00:00Z",
|
||||
"lastModified": "2025-10-02T00:00:00Z",
|
||||
"sha256": "cafebabe"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
[new Uri("https://api.cisco.test/csaf/cisco-sa-2025.json")] = QueueResponses("{ \"document\": \"payload\" }")
|
||||
};
|
||||
|
||||
var handler = new RoutingHttpMessageHandler(responses);
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var connectorOptions = new CiscoConnectorOptions
|
||||
{
|
||||
MetadataUri = "https://api.cisco.test/.well-known/csaf/provider-metadata.json",
|
||||
PersistOfflineSnapshot = false,
|
||||
};
|
||||
|
||||
var metadataLoader = new CiscoProviderMetadataLoader(
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Options.Create(connectorOptions),
|
||||
NullLogger<CiscoProviderMetadataLoader>.Instance,
|
||||
new MockFileSystem());
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new CiscoCsafConnector(
|
||||
metadataLoader,
|
||||
factory,
|
||||
stateRepository,
|
||||
new[] { new CiscoConnectorOptionsValidator() },
|
||||
NullLogger<CiscoCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
|
||||
|
||||
var providerStore = new StubProviderStore();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IVexProviderStore>(providerStore)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
null,
|
||||
VexConnectorSettings.Empty,
|
||||
sink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
services,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
var metadata = documents[0].Metadata;
|
||||
metadata.Should().Contain("vex.provenance.provider", "excititor:cisco");
|
||||
metadata.Should().Contain("vex.provenance.providerName", "Cisco");
|
||||
metadata.Should().Contain("vex.provenance.trust.weight", "0.75");
|
||||
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
|
||||
metadata.Should().Contain("vex.provenance.cosign.identityPattern", "https://sig.example.com/*");
|
||||
metadata.Should().Contain("vex.provenance.pgp.fingerprints", "0123456789ABCDEF,FEDCBA9876543210");
|
||||
|
||||
providerStore.SavedProviders.Should().HaveCount(1);
|
||||
var savedProvider = providerStore.SavedProviders[0];
|
||||
savedProvider.Id.Should().Be("excititor:cisco");
|
||||
savedProvider.Trust.Weight.Should().Be(0.75);
|
||||
savedProvider.Trust.Cosign.Should().NotBeNull();
|
||||
savedProvider.Trust.Cosign!.Issuer.Should().Be("https://issuer.example.com");
|
||||
savedProvider.Trust.Cosign.IdentityPattern.Should().Be("https://sig.example.com/*");
|
||||
savedProvider.Trust.PgpFingerprints.Should().Contain(new[] { "0123456789ABCDEF", "FEDCBA9876543210" });
|
||||
}
|
||||
|
||||
private static Queue<HttpResponseMessage> QueueResponses(string payload)
|
||||
=> new(new[]
|
||||
@@ -156,19 +271,36 @@ public sealed class CiscoCsafConnectorTests
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? CurrentState { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(CurrentState);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
CurrentState = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? CurrentState { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(CurrentState);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
CurrentState = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubProviderStore : IVexProviderStore
|
||||
{
|
||||
public List<VexProvider> SavedProviders { get; } = new();
|
||||
|
||||
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<VexProvider?>(null);
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(Array.Empty<VexProvider>());
|
||||
|
||||
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
SavedProviders.Add(provider);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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.SUSE.RancherVEXHub;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Connectors;
|
||||
|
||||
public sealed class RancherHubConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_OfflineSnapshot_StoresDocumentAndUpdatesCheckpoint()
|
||||
{
|
||||
using var fixture = await ConnectorFixture.CreateAsync();
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = fixture.CreateContext(sink);
|
||||
|
||||
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
var document = documents[0];
|
||||
document.Digest.Should().Be(fixture.ExpectedDocumentDigest);
|
||||
document.Metadata.Should().ContainKey("rancher.event.id").WhoseValue.Should().Be("evt-1");
|
||||
document.Metadata.Should().ContainKey("rancher.event.cursor").WhoseValue.Should().Be("cursor-2");
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
|
||||
var state = fixture.StateRepository.State;
|
||||
state.Should().NotBeNull();
|
||||
state!.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
|
||||
state.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
|
||||
state.DocumentDigests.Should().Contain("checkpoint:cursor-2");
|
||||
state.DocumentDigests.Count.Should().BeLessOrEqualTo(ConnectorFixture.MaxDigestHistory + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_WhenDocumentDownloadFails_QuarantinesEvent()
|
||||
{
|
||||
using var fixture = await ConnectorFixture.CreateAsync();
|
||||
|
||||
fixture.Handler.SetRoute(fixture.DocumentUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = fixture.CreateContext(sink);
|
||||
|
||||
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
var quarantined = sink.Documents[0];
|
||||
quarantined.Metadata.Should().Contain("rancher.event.quarantine", "true");
|
||||
quarantined.Metadata.Should().ContainKey("rancher.event.error").WhoseValue.Should().Contain("document fetch failed");
|
||||
|
||||
var state = fixture.StateRepository.State;
|
||||
state.Should().NotBeNull();
|
||||
state!.DocumentDigests.Should().Contain(d => d.StartsWith("quarantine:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_ReplayingSnapshot_SkipsDuplicateDocuments()
|
||||
{
|
||||
using var fixture = await ConnectorFixture.CreateAsync();
|
||||
|
||||
var firstSink = new InMemoryRawSink();
|
||||
var firstContext = fixture.CreateContext(firstSink);
|
||||
await CollectAsync(fixture.Connector.FetchAsync(firstContext, CancellationToken.None));
|
||||
|
||||
var secondSink = new InMemoryRawSink();
|
||||
var secondContext = fixture.CreateContext(secondSink);
|
||||
var secondRunDocuments = await CollectAsync(fixture.Connector.FetchAsync(secondContext, CancellationToken.None));
|
||||
|
||||
secondRunDocuments.Should().BeEmpty();
|
||||
secondSink.Documents.Should().BeEmpty();
|
||||
|
||||
var state = fixture.StateRepository.State;
|
||||
state.Should().NotBeNull();
|
||||
state!.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_TrimsPersistedDigestHistory()
|
||||
{
|
||||
var existingDigests = Enumerable.Range(0, ConnectorFixture.MaxDigestHistory + 5)
|
||||
.Select(i => $"sha256:{i:X32}")
|
||||
.ToImmutableArray();
|
||||
var initialState = new VexConnectorState(
|
||||
"excititor:suse.rancher",
|
||||
DateTimeOffset.Parse("2025-10-18T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
||||
ImmutableArray.CreateBuilder<string>()
|
||||
.Add("checkpoint:cursor-old")
|
||||
.AddRange(existingDigests)
|
||||
.ToImmutable());
|
||||
|
||||
using var fixture = await ConnectorFixture.CreateAsync(initialState);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = fixture.CreateContext(sink);
|
||||
await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
|
||||
|
||||
var state = fixture.StateRepository.State;
|
||||
state.Should().NotBeNull();
|
||||
state!.DocumentDigests.Should().Contain(d => d.StartsWith("checkpoint:", StringComparison.Ordinal));
|
||||
state.DocumentDigests.Count.Should().Be(ConnectorFixture.MaxDigestHistory + 1);
|
||||
}
|
||||
|
||||
private static async Task<List<VexRawDocument>> CollectAsync(IAsyncEnumerable<VexRawDocument> source)
|
||||
{
|
||||
var list = new List<VexRawDocument>();
|
||||
await foreach (var document in source.ConfigureAwait(false))
|
||||
{
|
||||
list.Add(document);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
#region helpers
|
||||
|
||||
private sealed class ConnectorFixture : IDisposable
|
||||
{
|
||||
public const int MaxDigestHistory = 200;
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly TempDirectory _tempDirectory;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private ConnectorFixture(
|
||||
RancherHubConnector connector,
|
||||
InMemoryConnectorStateRepository stateRepository,
|
||||
RoutingHttpMessageHandler handler,
|
||||
IServiceProvider serviceProvider,
|
||||
TempDirectory tempDirectory,
|
||||
HttpClient httpClient,
|
||||
Uri documentUri,
|
||||
string documentDigest)
|
||||
{
|
||||
Connector = connector;
|
||||
StateRepository = stateRepository;
|
||||
Handler = handler;
|
||||
_serviceProvider = serviceProvider;
|
||||
_tempDirectory = tempDirectory;
|
||||
_httpClient = httpClient;
|
||||
DocumentUri = documentUri;
|
||||
ExpectedDocumentDigest = $"sha256:{documentDigest}";
|
||||
}
|
||||
|
||||
public RancherHubConnector Connector { get; }
|
||||
|
||||
public InMemoryConnectorStateRepository StateRepository { get; }
|
||||
|
||||
public RoutingHttpMessageHandler Handler { get; }
|
||||
|
||||
public Uri DocumentUri { get; }
|
||||
|
||||
public string ExpectedDocumentDigest { get; }
|
||||
|
||||
public VexConnectorContext CreateContext(InMemoryRawSink sink, DateTimeOffset? since = null)
|
||||
=> new(
|
||||
since,
|
||||
VexConnectorSettings.Empty,
|
||||
sink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
_serviceProvider,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_tempDirectory.Dispose();
|
||||
}
|
||||
|
||||
public static async Task<ConnectorFixture> CreateAsync(VexConnectorState? initialState = null)
|
||||
{
|
||||
var tempDirectory = new TempDirectory();
|
||||
var documentPayload = "{\"document\":\"payload\"}";
|
||||
var documentDigest = ComputeSha256Hex(documentPayload);
|
||||
|
||||
var documentUri = new Uri("https://hub.test/events/evt-1.json");
|
||||
var eventsPayload = """
|
||||
{
|
||||
"cursor": "cursor-1",
|
||||
"nextCursor": "cursor-2",
|
||||
"events": [
|
||||
{
|
||||
"id": "evt-1",
|
||||
"type": "vex.statement.published",
|
||||
"channel": "rancher/rke2",
|
||||
"publishedAt": "2025-10-19T12:00:00Z",
|
||||
"document": {
|
||||
"uri": "https://hub.test/events/evt-1.json",
|
||||
"sha256": "DOC_DIGEST",
|
||||
"format": "csaf"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".Replace("DOC_DIGEST", documentDigest, StringComparison.Ordinal);
|
||||
|
||||
var eventsPath = tempDirectory.Combine("events.json");
|
||||
await File.WriteAllTextAsync(eventsPath, eventsPayload, Encoding.UTF8).ConfigureAwait(false);
|
||||
var eventsChecksum = ComputeSha256Hex(eventsPayload);
|
||||
|
||||
var discoveryPayload = """
|
||||
{
|
||||
"hubId": "excititor:suse.rancher",
|
||||
"title": "SUSE Rancher VEX Hub",
|
||||
"subscription": {
|
||||
"eventsUri": "https://hub.test/events",
|
||||
"checkpointUri": "https://hub.test/checkpoint",
|
||||
"channels": [ "rancher/rke2" ],
|
||||
"requiresAuthentication": false
|
||||
},
|
||||
"offline": {
|
||||
"snapshotUri": "EVENTS_URI",
|
||||
"sha256": "EVENTS_DIGEST"
|
||||
}
|
||||
}
|
||||
"""
|
||||
.Replace("EVENTS_URI", new Uri(eventsPath).ToString(), StringComparison.Ordinal)
|
||||
.Replace("EVENTS_DIGEST", eventsChecksum, StringComparison.Ordinal);
|
||||
|
||||
var discoveryPath = tempDirectory.Combine("discovery.json");
|
||||
await File.WriteAllTextAsync(discoveryPath, discoveryPayload, Encoding.UTF8).ConfigureAwait(false);
|
||||
|
||||
var handler = new RoutingHttpMessageHandler();
|
||||
handler.SetRoute(documentUri, () => JsonResponse(documentPayload));
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
var httpFactory = new SingletonHttpClientFactory(httpClient);
|
||||
|
||||
var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new System.IO.Abstractions.FileSystem();
|
||||
var tokenProvider = new RancherHubTokenProvider(httpFactory, memoryCache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var metadataLoader = new RancherHubMetadataLoader(httpFactory, memoryCache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
var eventClient = new RancherHubEventClient(httpFactory, tokenProvider, fileSystem, NullLogger<RancherHubEventClient>.Instance);
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository(initialState);
|
||||
var checkpointManager = new RancherHubCheckpointManager(stateRepository);
|
||||
|
||||
var validators = new[] { new RancherHubConnectorOptionsValidator(fileSystem) };
|
||||
var connector = new RancherHubConnector(
|
||||
metadataLoader,
|
||||
eventClient,
|
||||
checkpointManager,
|
||||
tokenProvider,
|
||||
httpFactory,
|
||||
NullLogger<RancherHubConnector>.Instance,
|
||||
TimeProvider.System,
|
||||
validators);
|
||||
|
||||
var settingsValues = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
settingsValues["DiscoveryUri"] = "https://hub.test/.well-known/rancher-hub.json";
|
||||
settingsValues["OfflineSnapshotPath"] = discoveryPath;
|
||||
settingsValues["PreferOfflineSnapshot"] = "true";
|
||||
var settings = new VexConnectorSettings(settingsValues.ToImmutable());
|
||||
await connector.ValidateAsync(settings, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
|
||||
return new ConnectorFixture(
|
||||
connector,
|
||||
stateRepository,
|
||||
handler,
|
||||
services,
|
||||
tempDirectory,
|
||||
httpClient,
|
||||
documentUri,
|
||||
documentDigest);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string payload)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingletonHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingletonHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, Queue<Func<HttpResponseMessage>>> _routes = new();
|
||||
|
||||
public void SetRoute(Uri uri, params Func<HttpResponseMessage>[] responders)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
if (responders is null || responders.Length == 0)
|
||||
{
|
||||
_routes.Remove(uri);
|
||||
return;
|
||||
}
|
||||
|
||||
_routes[uri] = new Queue<Func<HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri is not null &&
|
||||
_routes.TryGetValue(request.RequestUri, out var queue) &&
|
||||
queue.Count > 0)
|
||||
{
|
||||
var responder = queue.Count > 1 ? queue.Dequeue() : queue.Peek();
|
||||
var response = responder();
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No response configured for {request.RequestUri}", Encoding.UTF8, "text/plain"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public InMemoryConnectorStateRepository(VexConnectorState? initialState = null)
|
||||
{
|
||||
State = initialState;
|
||||
}
|
||||
|
||||
public VexConnectorState? State { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(State);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
_path = Path.Combine(Path.GetTempPath(), "stellaops-excititor-tests", Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(_path);
|
||||
}
|
||||
|
||||
public string Combine(string relative) => Path.Combine(_path, relative);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_path))
|
||||
{
|
||||
Directory.Delete(_path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string payload)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(payload);
|
||||
return ComputeSha256Hex(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(payload, buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -6,13 +6,18 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
|
||||
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -127,5 +127,53 @@ public sealed class CsafNormalizerTests
|
||||
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]
|
||||
public async Task NormalizeAsync_MissingJustification_AddsPolicyDiagnostic()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"document": {
|
||||
"tracking": {
|
||||
"initial_release_date": "2025-10-02T00:00:00Z",
|
||||
"current_release_date": "2025-10-03T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "pkg:example/app@1.0.0",
|
||||
"name": "Example App"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "VULN-1",
|
||||
"product_status": {
|
||||
"known_not_affected": [ "pkg:example/app@1.0.0" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
"excititor:example",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/csaf.json"),
|
||||
new DateTimeOffset(2025, 10, 4, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:digest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:example", "Example CSAF", VexProviderKind.Vendor);
|
||||
var normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Diagnostics.Should().ContainKey("policy.justification_missing");
|
||||
batch.Diagnostics["policy.justification_missing"].Should().Contain("VULN-1:pkg:example/app@1.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user