up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
cryptopro-linux-csp / build-and-test (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-09 09:38:09 +02:00
parent bc0762e97d
commit 108d1c64b3
193 changed files with 7265 additions and 13029 deletions

View File

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

View File

@@ -16,9 +16,7 @@ using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
@@ -323,10 +321,10 @@ public sealed class MsrcCsafConnectorTests
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
State = state;
return ValueTask.CompletedTask;

View File

@@ -17,10 +17,8 @@ using StellaOps.Excititor.Connectors.Oracle.CSAF;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Connectors;
@@ -257,10 +255,10 @@ public sealed class OracleCsafConnectorTests
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
State = state;
return ValueTask.CompletedTask;

View File

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

View File

@@ -9,10 +9,10 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,35 +1,34 @@
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);
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 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);
@@ -49,28 +48,28 @@ public sealed class RancherHubConnectorTests
"vex.provenance.pgp.fingerprints",
"11223344556677889900AABBCCDDEEFF00112233,AABBCCDDEEFF00112233445566778899AABBCCDD");
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();
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");
@@ -80,205 +79,205 @@ public sealed class RancherHubConnectorTests
quarantined.Metadata.Should().Contain("vex.provenance.trust.tier", "hub");
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);
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;
@@ -289,160 +288,160 @@ public sealed class RancherHubConnectorTests
settingsValues["PgpFingerprints:0"] = "AABBCCDDEEFF00112233445566778899AABBCCDD";
settingsValues["PgpFingerprints:1"] = "11223344556677889900AABBCCDDEEFF00112233";
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
}
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)
=> 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));
}
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
}

View File

@@ -10,7 +10,7 @@
</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" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />

View File

@@ -17,10 +17,8 @@ using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
@@ -374,10 +372,10 @@ public sealed class UbuntuCsafConnectorTests
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
CurrentState = state;
return ValueTask.CompletedTask;
@@ -399,13 +397,13 @@ public sealed class UbuntuCsafConnectorTests
{
public List<VexProvider> SavedProviders { get; } = new();
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken)
=> ValueTask.FromResult(SavedProviders.LastOrDefault(provider => provider.Id == id));
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(SavedProviders.ToList());
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken)
{
var existingIndex = SavedProviders.FindIndex(p => p.Id == provider.Id);
if (existingIndex >= 0)

View File

@@ -16,7 +16,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,9 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using Xunit;
@@ -86,10 +84,10 @@ public sealed class VexEvidenceChunkServiceTests
_claims = claims;
}
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken)
{
var query = _claims
.Where(claim => claim.VulnerabilityId == vulnerabilityId)

View File

@@ -4,12 +4,10 @@ using System.IO;
using System.Text;
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
namespace StellaOps.Excititor.Export.Tests;
@@ -212,14 +210,14 @@ public sealed class ExportEngineTests
public VexExportManifest? LastSavedManifest { get; private set; }
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
var key = CreateKey(signature.Value, format);
_store.TryGetValue(key, out var manifest);
return ValueTask.FromResult<VexExportManifest?>(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken)
{
var key = CreateKey(manifest.QuerySignature.Value, manifest.Format);
_store[key] = manifest;
@@ -299,13 +297,13 @@ public sealed class ExportEngineTests
{
public Dictionary<(string Signature, VexExportFormat Format), bool> RemoveCalls { get; } = new();
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexCacheEntry?>(null);
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
RemoveCalls[(signature.Value, format)] = true;
return ValueTask.CompletedTask;

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.Export.Tests;
@@ -53,13 +51,13 @@ public sealed class VexExportCacheServiceTests
public VexExportFormat LastFormat { get; private set; }
public int RemoveCalls { get; private set; }
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexCacheEntry?>(null);
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
LastSignature = signature;
LastFormat = format;
@@ -73,10 +71,10 @@ public sealed class VexExportCacheServiceTests
public int ExpiredCount { get; set; }
public int DanglingCount { get; set; }
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult(ExpiredCount);
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(DanglingCount);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,88 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
internal sealed class TestMongoEnvironment : IAsyncLifetime
{
private const string Prefix = "exstor";
private readonly MongoDbRunner? _runner;
private readonly HashSet<string> _reservedDatabases = new(StringComparer.Ordinal);
public TestMongoEnvironment()
{
var overrideConnection = Environment.GetEnvironmentVariable("EXCITITOR_TEST_MONGO_URI");
if (!string.IsNullOrWhiteSpace(overrideConnection))
{
ConnectionString = overrideConnection.Trim();
Client = new MongoClient(ConnectionString);
return;
}
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
ConnectionString = _runner.ConnectionString;
Client = new MongoClient(ConnectionString);
}
public MongoClient Client { get; }
public string ConnectionString { get; }
public string ReserveDatabase(string hint)
{
var baseName = string.IsNullOrWhiteSpace(hint) ? "db" : hint.ToLowerInvariant();
var builder = new StringBuilder(baseName.Length);
foreach (var ch in baseName)
{
builder.Append(char.IsLetterOrDigit(ch) ? ch : '_');
}
var slug = builder.Length == 0 ? "db" : builder.ToString();
var suffix = ObjectId.GenerateNewId().ToString();
var maxSlugLength = Math.Max(1, 60 - Prefix.Length - suffix.Length - 2);
if (slug.Length > maxSlugLength)
{
slug = slug[..maxSlugLength];
}
var name = $"{Prefix}_{slug}_{suffix}";
_reservedDatabases.Add(name);
return name;
}
public IMongoDatabase CreateDatabase(string hint)
{
var name = ReserveDatabase(hint);
return Client.GetDatabase(name);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_runner is not null)
{
_runner.Dispose();
return;
}
foreach (var db in _reservedDatabases)
{
try
{
await Client.DropDatabaseAsync(db);
}
catch (MongoException)
{
// best-effort cleanup when sharing a developer-managed instance.
}
}
_reservedDatabases.Clear();
}
}

View File

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

View File

@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
using Xunit;

View File

@@ -7,7 +7,6 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -22,21 +21,16 @@ public sealed class BatchIngestValidationTests : IDisposable
{
private const string Tenant = "tests";
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public BatchIngestValidationTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "vex_batch_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = Tenant,
["Excititor:Storage:DefaultTenant"] = Tenant,
});
},
configureServices: services =>
@@ -121,7 +115,6 @@ public sealed class BatchIngestValidationTests : IDisposable
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class IngestionMetricListener : IDisposable

View File

@@ -8,7 +8,6 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
using Xunit;

View File

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

View File

@@ -11,12 +11,10 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using EphemeralMongo;
using MongoDB.Bson;
using MongoDB.Driver;
using Xunit;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Tests;
@@ -24,20 +22,15 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class ObservabilityEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public ObservabilityEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor_obs_tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:DefaultTenant"] = "tests",
["Excititor:Observability:IngestWarningThreshold"] = "00:10:00",
["Excititor:Observability:IngestCriticalThreshold"] = "00:30:00",
["Excititor:Observability:SignatureWindow"] = "00:30:00",
@@ -51,7 +44,7 @@ public sealed class ObservabilityEndpointTests : IDisposable
services.AddTestAuthentication();
services.RemoveAll<IVexConnectorStateRepository>();
services.AddScoped<IVexConnectorStateRepository, MongoVexConnectorStateRepository>();
services.AddSingleton<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.AddSingleton<IVexConnector>(_ => new StubConnector("excititor:redhat", VexProviderKind.Distro));
});
@@ -94,46 +87,15 @@ public sealed class ObservabilityEndpointTests : IDisposable
private void SeedDatabase()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
database.DropCollection(VexMongoCollectionNames.Raw);
database.DropCollection(VexMongoCollectionNames.Consensus);
database.DropCollection(VexMongoCollectionNames.ConnectorState);
var now = DateTime.UtcNow;
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
rawCollection.InsertMany(new[]
{
new BsonDocument
{
{ "Id", "raw-1" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" }, { "signature.verified", "true" } } }
},
new BsonDocument
{
{ "Id", "raw-2" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" } } }
},
new BsonDocument
{
{ "Id", "raw-3" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument() }
}
});
var consensus = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Consensus);
consensus.InsertMany(new[]
{
ObservabilityEndpointTestsHelper.CreateConsensusDocument("c1", now, "affected"),
ObservabilityEndpointTestsHelper.CreateConsensusDocument("c2", now.AddMinutes(-5), "not_affected")
});
var rawStore = scope.ServiceProvider.GetRequiredService<IVexRawStore>();
var linksetStore = scope.ServiceProvider.GetRequiredService<IAppendOnlyLinksetStore>();
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
var stateRepository = scope.ServiceProvider.GetRequiredService<IVexConnectorStateRepository>();
var now = DateTimeOffset.UtcNow;
var provider = new VexProvider("excititor:redhat", "Red Hat", VexProviderKind.Distro);
providerStore.SaveAsync(provider, CancellationToken.None).GetAwaiter().GetResult();
var state = new VexConnectorState(
"excititor:redhat",
now.AddMinutes(-5),
@@ -144,12 +106,67 @@ public sealed class ObservabilityEndpointTests : IDisposable
now.AddMinutes(10),
null);
stateRepository.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult();
var metadataVerified = ImmutableDictionary<string, string>.Empty
.Add("signature.present", "true")
.Add("signature.verified", "true")
.Add("tenant", "tests");
var metadataUnsigned = ImmutableDictionary<string, string>.Empty
.Add("signature.present", "true")
.Add("tenant", "tests");
var metadataMissing = ImmutableDictionary<string, string>.Empty.Add("tenant", "tests");
rawStore.StoreAsync(new VexRawDocument(
"excititor:redhat",
VexDocumentFormat.Csaf,
new Uri("https://example.test/raw1.json"),
now,
"sha256:raw-1",
"{\"stub\":\"payload\"}"u8.ToArray(),
metadataVerified), CancellationToken.None).GetAwaiter().GetResult();
rawStore.StoreAsync(new VexRawDocument(
"excititor:redhat",
VexDocumentFormat.Csaf,
new Uri("https://example.test/raw2.json"),
now,
"sha256:raw-2",
"{\"stub\":\"payload\"}"u8.ToArray(),
metadataUnsigned), CancellationToken.None).GetAwaiter().GetResult();
rawStore.StoreAsync(new VexRawDocument(
"excititor:redhat",
VexDocumentFormat.Csaf,
new Uri("https://example.test/raw3.json"),
now,
"sha256:raw-3",
"{\"stub\":\"payload\"}"u8.ToArray(),
metadataMissing), CancellationToken.None).GetAwaiter().GetResult();
var scopeMetadata = new VexProductScope("pkg:test/demo", "demo", null, "pkg:test/demo", null, Array.Empty<string>());
linksetStore.AppendObservationsBatchAsync(
"tests",
"CVE-2025-0001",
"pkg:test/demo",
new[]
{
new VexLinksetObservationRefModel("obs-1", "excititor:redhat", "affected", 0.9),
new VexLinksetObservationRefModel("obs-2", "excititor:redhat", "fixed", 0.5)
},
scopeMetadata,
CancellationToken.None).GetAwaiter().GetResult();
linksetStore.AppendDisagreementAsync(
"tests",
"CVE-2025-0001",
"pkg:test/demo",
new VexObservationDisagreement("excititor:redhat", "affected", "coverage-gap", 0.7),
CancellationToken.None).GetAwaiter().GetResult();
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class StubConnector : IVexConnector
@@ -177,32 +194,3 @@ public sealed class ObservabilityEndpointTests : IDisposable
ImmutableDictionary<string, string>.Empty));
}
}
internal static class ObservabilityEndpointTestsHelper
{
public const string RetrievedAtField = "RetrievedAt";
public const string MetadataField = "Metadata";
public static BsonDocument CreateConsensusDocument(string id, DateTime timestamp, string conflictStatus)
{
var conflicts = new BsonArray
{
new BsonDocument
{
{ "ProviderId", "excititor:redhat" },
{ "Status", conflictStatus },
{ "DocumentDigest", Guid.NewGuid().ToString("n") }
}
};
return new BsonDocument
{
{ "Id", id },
{ "VulnerabilityId", $"CVE-{id}" },
{ "Product", new BsonDocument { { "Key", $"pkg:{id}" }, { "Name", $"pkg-{id}" } } },
{ "Status", "affected" },
{ "CalculatedAt", timestamp },
{ "Conflicts", conflicts }
};
}
}

View File

@@ -6,9 +6,6 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Policy;
@@ -23,11 +20,9 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class OpenApiDiscoveryEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public OpenApiDiscoveryEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
@@ -35,10 +30,7 @@ public sealed class OpenApiDiscoveryEndpointTests : IDisposable
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-openapi-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["Excititor:Storage:DefaultTenant"] = "tests",
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
};
config.AddInMemoryCollection(settings!);
@@ -173,7 +165,6 @@ public sealed class OpenApiDiscoveryEndpointTests : IDisposable
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class FakeSigner : IVexSigner

View File

@@ -2,7 +2,6 @@ using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Tests;
@@ -86,13 +85,13 @@ public sealed class PolicyEndpointsTests
_claims = claims;
}
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(_claims.Where(c => c.VulnerabilityId == vulnerabilityId && c.Product.Key == productKey).ToList());
public ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(_claims.Where(c => c.VulnerabilityId == vulnerabilityId).Take(limit).ToList());
}
}

View File

@@ -5,27 +5,20 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class ResolveEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public ResolveEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
@@ -33,10 +26,7 @@ public sealed class ResolveEndpointTests : IDisposable
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-resolve-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["Excititor:Storage:DefaultTenant"] = "tests",
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
};
config.AddInMemoryCollection(settings!);
@@ -197,7 +187,6 @@ public sealed class ResolveEndpointTests : IDisposable
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class ResolveRequest

View File

@@ -5,9 +5,6 @@ using System.Net.Http.Json;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EphemeralMongo;
using MongoRunner = EphemeralMongo.MongoRunner;
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Policy;
@@ -20,11 +17,9 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class StatusEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public StatusEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: config =>
{
@@ -32,10 +27,9 @@ public sealed class StatusEndpointTests : IDisposable
Directory.CreateDirectory(rootPath);
var settings = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-web-tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
["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!);
@@ -65,7 +59,6 @@ public sealed class StatusEndpointTests : IDisposable
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class StatusResponse

View File

@@ -11,7 +11,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="EphemeralMongo" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />

View File

@@ -1,31 +1,36 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Services;
using MongoDB.Driver;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
namespace StellaOps.Excititor.WebService.Tests;
internal static class TestServiceOverrides
{
public static void Apply(IServiceCollection services)
{
services.RemoveAll<IVexConnector>();
services.RemoveAll<IVexIngestOrchestrator>();
services.RemoveAll<IVexConnectorStateRepository>();
namespace StellaOps.Excititor.WebService.Tests;
internal static class TestServiceOverrides
{
public static void Apply(IServiceCollection services)
{
services.RemoveAll<IVexConnector>();
services.RemoveAll<IVexIngestOrchestrator>();
services.RemoveAll<IVexConnectorStateRepository>();
services.RemoveAll<IVexRawStore>();
services.RemoveAll<IAppendOnlyLinksetStore>();
services.RemoveAll<IVexLinksetStore>();
services.RemoveAll<IVexObservationStore>();
services.RemoveAll<IVexClaimStore>();
services.RemoveAll<IVexExportCacheService>();
services.RemoveAll<IVexExportDataSource>();
services.RemoveAll<IVexExportStore>();
@@ -37,110 +42,115 @@ internal static class TestServiceOverrides
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
services.AddSingleton<IVexRawStore, InMemoryVexRawStore>();
services.AddSingleton<IAppendOnlyLinksetStore, InMemoryAppendOnlyLinksetStore>();
services.AddSingleton<IVexLinksetStore>(sp => (IVexLinksetStore)sp.GetRequiredService<IAppendOnlyLinksetStore>());
services.AddSingleton<IVexObservationStore, InMemoryVexObservationStore>();
services.AddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
services.RemoveAll<IExportEngine>();
services.AddSingleton<IExportEngine, StubExportEngine>();
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
services.RemoveAll<IExportEngine>();
services.AddSingleton<IExportEngine, StubExportEngine>();
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
services.AddSingleton<IVexExportStore, StubExportStore>();
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
services.AddSingleton<IVexSigner, StubSigner>();
services.AddSingleton<IAirgapImportStore, StubAirgapImportStore>();
services.RemoveAll<IHostedService>();
services.AddSingleton<IHostedService, NoopHostedService>();
}
private sealed class StubExportCacheService : IVexExportCacheService
{
public ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
}
private sealed class StubExportEngine : IExportEngine
{
public ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken)
{
var manifest = new VexExportManifest(
exportId: "stub/export",
querySignature: VexQuerySignature.FromQuery(context.Query),
format: context.Format,
createdAt: DateTimeOffset.UtcNow,
artifact: new VexContentAddress("sha256", "stub"),
claimCount: 0,
sourceProviders: Array.Empty<string>());
return ValueTask.FromResult(manifest);
}
}
private sealed class StubExportDataSource : IVexExportDataSource
{
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
return ValueTask.FromResult(new VexExportDataSet(
ImmutableArray<VexConsensus>.Empty,
ImmutableArray<VexClaim>.Empty,
ImmutableArray<string>.Empty));
}
}
private sealed class StubExportStore : IVexExportStore
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _store = new();
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_store.TryGetValue((signature.Value, format), out var manifest);
return ValueTask.FromResult(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_store[(manifest.QuerySignature.Value, manifest.Format)] = manifest;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheIndex : IVexCacheIndex
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexCacheEntry> _entries = new();
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries.TryGetValue((signature.Value, format), out var entry);
return ValueTask.FromResult(entry);
}
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries.TryRemove((signature.Value, format), out _);
return ValueTask.CompletedTask;
}
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries[(entry.QuerySignature.Value, entry.Format)] = entry;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheMaintenance : IVexCacheMaintenance
{
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0);
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0);
}
services.RemoveAll<IHostedService>();
services.AddSingleton<IHostedService, NoopHostedService>();
}
private sealed class StubExportCacheService : IVexExportCacheService
{
public ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
}
private sealed class StubExportEngine : IExportEngine
{
public ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken)
{
var manifest = new VexExportManifest(
exportId: "stub/export",
querySignature: VexQuerySignature.FromQuery(context.Query),
format: context.Format,
createdAt: DateTimeOffset.UtcNow,
artifact: new VexContentAddress("sha256", "stub"),
claimCount: 0,
sourceProviders: Array.Empty<string>());
return ValueTask.FromResult(manifest);
}
}
private sealed class StubExportDataSource : IVexExportDataSource
{
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
return ValueTask.FromResult(new VexExportDataSet(
ImmutableArray<VexConsensus>.Empty,
ImmutableArray<VexClaim>.Empty,
ImmutableArray<string>.Empty));
}
}
private sealed class StubExportStore : IVexExportStore
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _store = new();
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
_store.TryGetValue((signature.Value, format), out var manifest);
return ValueTask.FromResult(manifest);
}
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken)
{
_store[(manifest.QuerySignature.Value, manifest.Format)] = manifest;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheIndex : IVexCacheIndex
{
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexCacheEntry> _entries = new();
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
_entries.TryGetValue((signature.Value, format), out var entry);
return ValueTask.FromResult(entry);
}
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
_entries.TryRemove((signature.Value, format), out _);
return ValueTask.CompletedTask;
}
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken)
{
_entries[(entry.QuerySignature.Value, entry.Format)] = entry;
return ValueTask.CompletedTask;
}
}
private sealed class StubCacheMaintenance : IVexCacheMaintenance
{
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(0);
}
private sealed class StubAttestationClient : IVexAttestationClient
{
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
@@ -166,31 +176,31 @@ internal static class TestServiceOverrides
diagnostics);
return ValueTask.FromResult(response);
}
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
{
var verification = new VexAttestationVerification(true, VexAttestationDiagnostics.Empty);
return ValueTask.FromResult(verification);
}
}
}
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
{
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
IReadOnlyCollection<VexConnectorState> snapshot = _states.Values.ToList();
return ValueTask.FromResult(snapshot);
@@ -288,26 +298,26 @@ internal static class TestServiceOverrides
return Task.FromResult(count);
}
}
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
{
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new InitSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<InitProviderResult>.Empty));
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new ReconcileSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ReconcileProviderResult>.Empty));
}
private sealed class NoopHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
{
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new InitSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<InitProviderResult>.Empty));
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new ReconcileSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ReconcileProviderResult>.Empty));
}
private sealed class NoopHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}

View File

@@ -6,7 +6,6 @@ using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Storage.Mongo.Migrations;
namespace StellaOps.Excititor.WebService.Tests;
@@ -39,9 +38,9 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
var defaults = new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = "mongodb://localhost:27017",
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "test",
["Postgres:Excititor:ConnectionString"] = "Host=localhost;Username=postgres;Password=postgres;Database=excititor_tests",
["Postgres:Excititor:SchemaName"] = "vex",
["Excititor:Storage:DefaultTenant"] = "test",
};
config.AddInMemoryCollection(defaults);
_configureConfiguration?.Invoke(config);

View File

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

View File

@@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using Xunit;
@@ -86,10 +85,10 @@ public sealed class VexEvidenceChunkServiceTests
_claims = claims;
}
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken)
{
var query = _claims
.Where(claim => claim.VulnerabilityId == vulnerabilityId)
@@ -102,6 +101,16 @@ public sealed class VexEvidenceChunkServiceTests
return ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(query.ToList());
}
public ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken)
{
var results = _claims
.Where(claim => claim.VulnerabilityId == vulnerabilityId)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(results);
}
}
private sealed class FixedTimeProvider : TimeProvider

View File

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

View File

@@ -1,13 +1,15 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http.Json;
using EphemeralMongo;
using System.Threading;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Storage.Mongo;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
@@ -15,21 +17,16 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexLinksetListEndpointTests : IDisposable
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexLinksetListEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "linksets_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "tests",
["Excititor:Storage:DefaultTenant"] = "tests",
});
},
configureServices: services =>
@@ -56,7 +53,8 @@ public sealed class VexLinksetListEndpointTests : IDisposable
Assert.Single(payload!.Items);
var item = payload.Items.Single();
Assert.Equal("CVE-2025-0001:pkg:demo/app", item.LinksetId);
var expectedId = VexLinkset.CreateLinksetId("tests", "CVE-2025-0001", "pkg:demo/app");
Assert.Equal(expectedId, item.LinksetId);
Assert.Equal("CVE-2025-0001", item.VulnerabilityId);
Assert.Equal("pkg:demo/app", item.ProductKey);
@@ -69,72 +67,34 @@ public sealed class VexLinksetListEndpointTests : IDisposable
private void SeedObservations()
{
var client = new MongoClient(_runner.ConnectionString);
var database = client.GetDatabase("linksets_tests");
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Observations);
using var scope = _factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IAppendOnlyLinksetStore>();
var observations = new List<BsonDocument>
var scopeMetadata = new VexProductScope(
key: "pkg:demo/app",
name: "demo app",
version: null,
purl: "pkg:demo/app",
cpe: null,
componentIdentifiers: Array.Empty<string>());
var observations = new[]
{
new()
{
{ "_id", "obs-1" },
{ "Tenant", "tests" },
{ "ObservationId", "obs-1" },
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "ProviderId", "provider-a" },
{ "Status", "affected" },
{ "StreamId", "stream" },
{ "CreatedAt", DateTime.UtcNow },
{ "Document", new BsonDocument { { "Digest", "digest-1" }, { "Format", "csaf" }, { "SourceUri", "https://example.test/a.json" } } },
{ "Statements", new BsonArray
{
new BsonDocument
{
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "Status", "affected" },
{ "LastObserved", DateTime.UtcNow },
{ "Purl", "pkg:demo/app" }
}
}
},
{ "Linkset", new BsonDocument { { "Purls", new BsonArray { "pkg:demo/app" } } } }
},
new()
{
{ "_id", "obs-2" },
{ "Tenant", "tests" },
{ "ObservationId", "obs-2" },
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "ProviderId", "provider-b" },
{ "Status", "fixed" },
{ "StreamId", "stream" },
{ "CreatedAt", DateTime.UtcNow.AddMinutes(1) },
{ "Document", new BsonDocument { { "Digest", "digest-2" }, { "Format", "csaf" }, { "SourceUri", "https://example.test/b.json" } } },
{ "Statements", new BsonArray
{
new BsonDocument
{
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "Status", "fixed" },
{ "LastObserved", DateTime.UtcNow },
{ "Purl", "pkg:demo/app" }
}
}
},
{ "Linkset", new BsonDocument { { "Purls", new BsonArray { "pkg:demo/app" } } } }
}
new VexLinksetObservationRefModel("obs-1", "provider-a", "affected", 0.8),
new VexLinksetObservationRefModel("obs-2", "provider-b", "fixed", 0.9),
};
collection.InsertMany(observations);
store.AppendObservationsBatchAsync(
tenant: "tests",
vulnerabilityId: "CVE-2025-0001",
productKey: "pkg:demo/app",
observations: observations,
scope: scopeMetadata,
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}

View File

@@ -1,13 +1,15 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http.Json;
using EphemeralMongo;
using System.Text.Json.Nodes;
using System.Threading;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Storage.Mongo;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
@@ -15,21 +17,16 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexObservationListEndpointTests : IDisposable
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexObservationListEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "observations_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "tests",
["Excititor:Storage:DefaultTenant"] = "tests",
});
},
configureServices: services =>
@@ -66,45 +63,55 @@ public sealed class VexObservationListEndpointTests : IDisposable
private void SeedObservation()
{
var client = new MongoClient(_runner.ConnectionString);
var database = client.GetDatabase("observations_tests");
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Observations);
using var scope = _factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IVexObservationStore>();
var record = new BsonDocument
{
{ "_id", "obs-1" },
{ "Tenant", "tests" },
{ "ObservationId", "obs-1" },
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "ProviderId", "provider-a" },
{ "Status", "affected" },
{ "StreamId", "stream" },
{ "CreatedAt", DateTime.UtcNow },
{ "Document", new BsonDocument { { "Digest", "digest-1" }, { "Format", "csaf" }, { "SourceUri", "https://example.test/vex.json" } } },
{ "Upstream", new BsonDocument { { "UpstreamId", "up-1" }, { "ContentHash", "sha256:digest-1" }, { "Signature", new BsonDocument { { "Present", true }, { "Subject", "sub" }, { "Issuer", "iss" }, { "VerifiedAt", DateTime.UtcNow } } } } },
{ "Content", new BsonDocument { { "Format", "csaf" }, { "Raw", new BsonDocument { { "document", "payload" } } } } },
{ "Statements", new BsonArray
{
new BsonDocument
{
{ "VulnerabilityId", "cve-2025-0001" },
{ "ProductKey", "pkg:demo/app" },
{ "Status", "affected" },
{ "LastObserved", DateTime.UtcNow },
{ "Purl", "pkg:demo/app" }
}
}
},
{ "Linkset", new BsonDocument { { "Purls", new BsonArray { "pkg:demo/app" } } } }
};
var now = DateTimeOffset.Parse("2025-12-01T00:00:00Z");
var observation = new VexObservation(
observationId: "obs-1",
tenant: "tests",
providerId: "provider-a",
streamId: "stream",
upstream: new VexObservationUpstream(
upstreamId: "up-1",
documentVersion: "1",
fetchedAt: now,
receivedAt: now,
contentHash: "sha256:digest-1",
signature: new VexObservationSignature(
present: true,
format: "dsse",
keyId: "key-1",
signature: "stub-signature")),
statements: ImmutableArray.Create(new VexObservationStatement(
vulnerabilityId: "cve-2025-0001",
productKey: "pkg:demo/app",
status: VexClaimStatus.Affected,
lastObserved: now,
locator: null,
justification: null,
introducedVersion: null,
fixedVersion: null,
purl: "pkg:demo/app",
cpe: null,
evidence: null,
metadata: null)),
content: new VexObservationContent(
format: "csaf",
specVersion: "2.0",
raw: JsonNode.Parse("{\"document\":\"payload\"}")!),
linkset: new VexObservationLinkset(
aliases: new[] { "cve-2025-0001" },
purls: new[] { "pkg:demo/app" },
cpes: Array.Empty<string>(),
references: Array.Empty<VexObservationReference>()),
createdAt: now);
collection.InsertOne(record);
store.InsertAsync(observation, CancellationToken.None).GetAwaiter().GetResult();
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}

View File

@@ -6,9 +6,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using Xunit;
@@ -119,10 +117,10 @@ public sealed class VexObservationProjectionServiceTests
_claims = claims;
}
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken)
{
var query = _claims
.Where(claim => string.Equals(claim.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase))
@@ -135,6 +133,16 @@ public sealed class VexObservationProjectionServiceTests
return ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(query.ToList());
}
public ValueTask<IReadOnlyCollection<VexClaim>> FindByVulnerabilityAsync(string vulnerabilityId, int limit, CancellationToken cancellationToken)
{
var results = _claims
.Where(claim => string.Equals(claim.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase))
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(results);
}
}
private sealed class FixedTimeProvider : TimeProvider

View File

@@ -3,34 +3,26 @@ using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexRawEndpointsTests : IDisposable
public sealed class VexRawEndpointsTests
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexRawEndpointsTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "vex_raw_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "tests",
["Excititor:Storage:DefaultTenant"] = "tests",
});
},
configureServices: services =>
@@ -99,9 +91,4 @@ public sealed class VexRawEndpointsTests : IDisposable
});
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}

View File

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

View File

@@ -11,7 +11,6 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using Xunit;
@@ -338,19 +337,19 @@ public class VexWorkerOrchestratorClientTests
{
private readonly Dictionary<string, VexConnectorState> _states = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(_states.Values.ToList());
}

View File

@@ -11,7 +11,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
@@ -25,6 +24,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Excititor.Worker/StellaOps.Excititor.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
</ItemGroup>
</Project>
</Project>