Add channel test providers for Email, Slack, Teams, and Webhook
- 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