Add channel test providers for Email, Slack, Teams, and Webhook
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:
2025-10-19 23:29:34 +03:00
parent 8e7ce55542
commit 5fd4032c7c
239 changed files with 17245 additions and 3155 deletions

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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));
}
}
}