using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text; using Microsoft.Extensions.Options; using Mongo2Go; using MongoDB.Bson; using MongoDB.Driver; using StellaOps.Excititor.Core; namespace StellaOps.Excititor.Storage.Mongo.Tests; public sealed class MongoVexRepositoryTests : IAsyncLifetime { private readonly MongoDbRunner _runner; private readonly MongoClient _client; public MongoVexRepositoryTests() { _runner = MongoDbRunner.Start(); _client = new MongoClient(_runner.ConnectionString); } [Fact] public async Task RawStore_UsesGridFsForLargePayloads() { var database = _client.GetDatabase($"vex-raw-gridfs-{Guid.NewGuid():N}"); var store = CreateRawStore(database, thresholdBytes: 32); var payload = Encoding.UTF8.GetBytes(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.Empty); await store.StoreAsync(document, CancellationToken.None); var rawCollection = database.GetCollection(VexMongoCollectionNames.Raw); var stored = await rawCollection.Find(Builders.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("vex.raw.files"); var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition.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 = _client.GetDatabase($"vex-raw-inline-{Guid.NewGuid():N}"); var store = CreateRawStore(database, thresholdBytes: 16); var largePayload = Encoding.UTF8.GetBytes(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.Empty); await store.StoreAsync(largeDocument, CancellationToken.None); var smallDocument = largeDocument with { RetrievedAt = DateTimeOffset.UtcNow.AddMinutes(1), Content = Encoding.UTF8.GetBytes("small"), }; await store.StoreAsync(smallDocument, CancellationToken.None); var rawCollection = database.GetCollection(VexMongoCollectionNames.Raw); var stored = await rawCollection.Find(Builders.Filter.Eq("_id", digest)) .FirstOrDefaultAsync(); Assert.NotNull(stored); Assert.True(stored!.TryGetValue("GridFsObjectId", out var gridId)); Assert.True(gridId.IsBsonNull); Assert.Equal("small", Encoding.UTF8.GetString(stored["Content"].AsBsonBinaryData.Bytes)); var filesCollection = database.GetCollection("vex.raw.files"); var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition.Empty); Assert.Equal(0, fileCount); } [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(VexMongoCollectionNames.Exports); var exportKey = BuildExportKey(signature, VexExportFormat.Csaf); var exportDoc = await exportsCollection.Find(Builders.Filter.Eq("_id", exportKey)) .FirstOrDefaultAsync(); Assert.NotNull(exportDoc); var cacheCollection = database.GetCollection(VexMongoCollectionNames.Cache); var cacheKey = BuildExportKey(signature, VexExportFormat.Csaf); var cacheDoc = await cacheCollection.Find(Builders.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 = _client.GetDatabase($"vex-export-expire-{Guid.NewGuid():N}"); 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(VexMongoCollectionNames.Cache); var cacheId = BuildExportKey(signature, VexExportFormat.Json); var update = Builders.Update.Set("ExpiresAt", DateTime.UtcNow.AddMinutes(-10)); await cacheCollection.UpdateOneAsync(Builders.Filter.Eq("_id", cacheId), update); var cached = await store.FindAsync(signature, VexExportFormat.Json, CancellationToken.None); Assert.Null(cached); var remaining = await cacheCollection.Find(Builders.Filter.Eq("_id", cacheId)) .FirstOrDefaultAsync(); Assert.Null(remaining); } [Fact] public async Task ClaimStore_AppendsAndQueriesStatements() { var database = _client.GetDatabase($"vex-claims-{Guid.NewGuid():N}"); 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.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) { var options = Options.Create(new VexMongoStorageOptions { RawBucketName = "vex.raw", GridFsInlineThresholdBytes = thresholdBytes, ExportCacheTtl = TimeSpan.FromHours(1), }); var sessionProvider = new VexMongoSessionProvider(_client, options); return new MongoVexRawStore(_client, database, options, sessionProvider); } 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() { _runner.Dispose(); return Task.CompletedTask; } }