283 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			283 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
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<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 = _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<string, string>.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<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);
 | 
						|
        Assert.Equal("small", Encoding.UTF8.GetString(stored["Content"].AsBsonBinaryData.Bytes));
 | 
						|
 | 
						|
        var filesCollection = database.GetCollection<BsonDocument>("vex.raw.files");
 | 
						|
        var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.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<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 = _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<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 = _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<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)
 | 
						|
    {
 | 
						|
        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;
 | 
						|
    }
 | 
						|
}
 |