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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user