Add channel test providers for Email, Slack, Teams, and Webhook
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
		| @@ -108,7 +108,8 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime | ||||
|             GridFsInlineThresholdBytes = 64, | ||||
|         }); | ||||
|  | ||||
|         var store = new MongoVexExportStore(_client, database, options); | ||||
|         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", | ||||
| @@ -152,7 +153,8 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime | ||||
|             GridFsInlineThresholdBytes = 64, | ||||
|         }); | ||||
|  | ||||
|         var store = new MongoVexExportStore(_client, database, options); | ||||
|         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", | ||||
| @@ -263,7 +265,8 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime | ||||
|             ExportCacheTtl = TimeSpan.FromHours(1), | ||||
|         }); | ||||
|  | ||||
|         return new MongoVexRawStore(_client, database, options); | ||||
|         var sessionProvider = new VexMongoSessionProvider(_client, options); | ||||
|         return new MongoVexRawStore(_client, database, options, sessionProvider); | ||||
|     } | ||||
|  | ||||
|     private static string BuildExportKey(VexQuerySignature signature, VexExportFormat format) | ||||
|   | ||||
| @@ -0,0 +1,184 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| 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 MongoVexSessionConsistencyTests : IAsyncLifetime | ||||
| { | ||||
|     private readonly MongoDbRunner _runner; | ||||
|  | ||||
|     public MongoVexSessionConsistencyTests() | ||||
|     { | ||||
|         _runner = MongoDbRunner.Start(singleNodeReplSet: true); | ||||
|     } | ||||
|  | ||||
|     [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 = _runner.ConnectionString; | ||||
|             options.DatabaseName = $"excititor-session-tests-{Guid.NewGuid():N}"; | ||||
|             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() | ||||
|     { | ||||
|         _runner.Dispose(); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,170 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Mongo2Go; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Storage.Mongo.Tests; | ||||
|  | ||||
| public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime | ||||
| { | ||||
|     private readonly MongoDbRunner _runner; | ||||
|  | ||||
|     public MongoVexStatementBackfillServiceTests() | ||||
|     { | ||||
|         _runner = MongoDbRunner.Start(singleNodeReplSet: true); | ||||
|     } | ||||
|  | ||||
|     [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", | ||||
|             ReadOnlyMemory<byte>.Empty, | ||||
|             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", | ||||
|             ReadOnlyMemory<byte>.Empty, | ||||
|             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 = _runner.ConnectionString; | ||||
|             options.DatabaseName = $"excititor-backfill-tests-{Guid.NewGuid():N}"; | ||||
|             options.CommandTimeout = TimeSpan.FromSeconds(5); | ||||
|             options.RawBucketName = "vex.raw"; | ||||
|             options.GridFsInlineThresholdBytes = 1024; | ||||
|             options.ExportCacheTtl = TimeSpan.FromHours(1); | ||||
|         }); | ||||
|         services.AddExcititorMongoStorage(); | ||||
|         services.AddSingleton<IVexNormalizer, TestNormalizer>(); | ||||
|         return services.BuildServiceProvider(); | ||||
|     } | ||||
|  | ||||
|     public Task InitializeAsync() => Task.CompletedTask; | ||||
|  | ||||
|     public Task DisposeAsync() | ||||
|     { | ||||
|         _runner.Dispose(); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     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)); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user