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:
master
2025-10-19 23:29:34 +03:00
parent a811f7ac47
commit a07f46231b
239 changed files with 17245 additions and 3155 deletions

View File

@@ -0,0 +1,82 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Conflicts;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Storage.Mongo.Tests;
[Collection("mongo-fixture")]
public sealed class AdvisoryConflictStoreTests
{
private readonly IMongoDatabase _database;
public AdvisoryConflictStoreTests(MongoIntegrationFixture fixture)
{
_database = fixture.Database ?? throw new ArgumentNullException(nameof(fixture.Database));
}
[Fact]
public async Task InsertAndRetrieve_PersistsConflicts()
{
var store = new AdvisoryConflictStore(_database);
var vulnerabilityKey = $"CVE-{Guid.NewGuid():N}";
var baseTime = DateTimeOffset.UtcNow;
var statementIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
var conflict = new AdvisoryConflictRecord(
Guid.NewGuid(),
vulnerabilityKey,
new byte[] { 0x10, 0x20 },
baseTime,
baseTime.AddSeconds(30),
statementIds,
new BsonDocument("explanation", "first-pass"));
await store.InsertAsync(new[] { conflict }, CancellationToken.None);
var results = await store.GetConflictsAsync(vulnerabilityKey, null, CancellationToken.None);
Assert.Single(results);
Assert.Equal(conflict.Id, results[0].Id);
Assert.Equal(statementIds, results[0].StatementIds);
}
[Fact]
public async Task GetConflicts_AsOfFilters()
{
var store = new AdvisoryConflictStore(_database);
var vulnerabilityKey = $"CVE-{Guid.NewGuid():N}";
var baseTime = DateTimeOffset.UtcNow;
var earlyConflict = new AdvisoryConflictRecord(
Guid.NewGuid(),
vulnerabilityKey,
new byte[] { 0x01 },
baseTime,
baseTime.AddSeconds(10),
new[] { Guid.NewGuid() },
new BsonDocument("stage", "early"));
var lateConflict = new AdvisoryConflictRecord(
Guid.NewGuid(),
vulnerabilityKey,
new byte[] { 0x02 },
baseTime.AddMinutes(10),
baseTime.AddMinutes(10).AddSeconds(15),
new[] { Guid.NewGuid() },
new BsonDocument("stage", "late"));
await store.InsertAsync(new[] { earlyConflict, lateConflict }, CancellationToken.None);
var results = await store.GetConflictsAsync(vulnerabilityKey, baseTime.AddMinutes(1), CancellationToken.None);
Assert.Single(results);
Assert.Equal("early", results[0].Details["stage"].AsString);
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Storage.Mongo.Tests;
[Collection("mongo-fixture")]
public sealed class AdvisoryStatementStoreTests
{
private readonly IMongoDatabase _database;
public AdvisoryStatementStoreTests(MongoIntegrationFixture fixture)
{
_database = fixture.Database ?? throw new ArgumentNullException(nameof(fixture.Database));
}
[Fact]
public async Task InsertAndRetrieve_WritesImmutableStatements()
{
var store = new AdvisoryStatementStore(_database);
var vulnerabilityKey = $"CVE-{Guid.NewGuid():N}";
var baseTime = DateTimeOffset.UtcNow;
var statements = new[]
{
new AdvisoryStatementRecord(
Guid.NewGuid(),
vulnerabilityKey,
vulnerabilityKey,
new byte[] { 0x01 },
baseTime,
baseTime.AddSeconds(5),
new BsonDocument("version", "A"),
new[] { Guid.NewGuid() }),
new AdvisoryStatementRecord(
Guid.NewGuid(),
vulnerabilityKey,
vulnerabilityKey,
new byte[] { 0x02 },
baseTime.AddMinutes(1),
baseTime.AddMinutes(1).AddSeconds(5),
new BsonDocument("version", "B"),
Array.Empty<Guid>()),
};
await store.InsertAsync(statements, CancellationToken.None);
var results = await store.GetStatementsAsync(vulnerabilityKey, null, CancellationToken.None);
Assert.Equal(2, results.Count);
Assert.Equal(statements[1].Id, results[0].Id); // sorted by AsOf desc
Assert.True(results.All(record => record.Payload.Contains("version")));
}
[Fact]
public async Task GetStatements_AsOfFiltersResults()
{
var store = new AdvisoryStatementStore(_database);
var vulnerabilityKey = $"CVE-{Guid.NewGuid():N}";
var baseTime = DateTimeOffset.UtcNow;
var early = new AdvisoryStatementRecord(
Guid.NewGuid(),
vulnerabilityKey,
vulnerabilityKey,
new byte[] { 0xAA },
baseTime,
baseTime.AddSeconds(10),
new BsonDocument("state", "early"),
Array.Empty<Guid>());
var late = new AdvisoryStatementRecord(
Guid.NewGuid(),
vulnerabilityKey,
vulnerabilityKey,
new byte[] { 0xBB },
baseTime.AddMinutes(5),
baseTime.AddMinutes(5).AddSeconds(10),
new BsonDocument("state", "late"),
Array.Empty<Guid>());
await store.InsertAsync(new[] { early, late }, CancellationToken.None);
var results = await store.GetStatementsAsync(vulnerabilityKey, baseTime.AddMinutes(1), CancellationToken.None);
Assert.Single(results);
Assert.Equal("early", results[0].Payload["state"].AsString);
}
}

View File

@@ -215,15 +215,59 @@ public sealed class MongoMigrationRunnerTests
Assert.DoesNotContain(indexList, x => x["name"].AsString == "gridfs_files_expiresAt_ttl");
}
finally
{
await _fixture.Client.DropDatabaseAsync(databaseName);
}
}
private sealed class TestMigration : IMongoMigration
{
public int ApplyCount { get; private set; }
finally
{
await _fixture.Client.DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task EnsureAdvisoryEventCollectionsMigration_CreatesIndexes()
{
var databaseName = $"concelier-advisory-events-{Guid.NewGuid():N}";
var database = _fixture.Client.GetDatabase(databaseName);
await database.CreateCollectionAsync(MongoStorageDefaults.Collections.AdvisoryStatements);
await database.CreateCollectionAsync(MongoStorageDefaults.Collections.AdvisoryConflicts);
await database.CreateCollectionAsync(MongoStorageDefaults.Collections.Migrations);
try
{
var migration = new EnsureAdvisoryEventCollectionsMigration();
var runner = new MongoMigrationRunner(
database,
new IMongoMigration[] { migration },
NullLogger<MongoMigrationRunner>.Instance,
TimeProvider.System);
await runner.RunAsync(CancellationToken.None);
var statementIndexes = await database
.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryStatements)
.Indexes
.ListAsync();
var statementIndexNames = (await statementIndexes.ToListAsync()).Select(x => x["name"].AsString).ToArray();
Assert.Contains("advisory_statements_vulnerability_asof_desc", statementIndexNames);
Assert.Contains("advisory_statements_statementHash_unique", statementIndexNames);
var conflictIndexes = await database
.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryConflicts)
.Indexes
.ListAsync();
var conflictIndexNames = (await conflictIndexes.ToListAsync()).Select(x => x["name"].AsString).ToArray();
Assert.Contains("advisory_conflicts_vulnerability_asof_desc", conflictIndexNames);
Assert.Contains("advisory_conflicts_conflictHash_unique", conflictIndexNames);
}
finally
{
await _fixture.Client.DropDatabaseAsync(databaseName);
}
}
private sealed class TestMigration : IMongoMigration
{
public int ApplyCount { get; private set; }
public string Id => "999_test";

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Conflicts;
using StellaOps.Concelier.Storage.Mongo.Events;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Storage.Mongo.Tests;
[Collection("mongo-fixture")]
public sealed class MongoAdvisoryEventRepositoryTests
{
private readonly IMongoDatabase _database;
private readonly MongoAdvisoryEventRepository _repository;
public MongoAdvisoryEventRepositoryTests(MongoIntegrationFixture fixture)
{
_database = fixture.Database ?? throw new ArgumentNullException(nameof(fixture.Database));
var statementStore = new AdvisoryStatementStore(_database);
var conflictStore = new AdvisoryConflictStore(_database);
_repository = new MongoAdvisoryEventRepository(statementStore, conflictStore);
}
[Fact]
public async Task InsertAndFetchStatements_RoundTripsCanonicalPayload()
{
var advisory = CreateSampleAdvisory("CVE-2025-7777", "Sample advisory");
var canonicalJson = CanonicalJsonSerializer.Serialize(advisory);
var hash = ImmutableArray.Create(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson)));
var entry = new AdvisoryStatementEntry(
Guid.NewGuid(),
"CVE-2025-7777",
"CVE-2025-7777",
canonicalJson,
hash,
DateTimeOffset.Parse("2025-10-19T14:00:00Z"),
DateTimeOffset.Parse("2025-10-19T14:05:00Z"),
ImmutableArray<Guid>.Empty);
await _repository.InsertStatementsAsync(new[] { entry }, CancellationToken.None);
var results = await _repository.GetStatementsAsync("CVE-2025-7777", null, CancellationToken.None);
var snapshot = Assert.Single(results);
Assert.Equal(entry.StatementId, snapshot.StatementId);
Assert.Equal(entry.CanonicalJson, snapshot.CanonicalJson);
Assert.True(entry.StatementHash.SequenceEqual(snapshot.StatementHash));
}
[Fact]
public async Task InsertAndFetchConflicts_PreservesDetails()
{
var detailJson = CanonicalJsonSerializer.Serialize(new ConflictPayload("severity", "mismatch"));
var hash = ImmutableArray.Create(SHA256.HashData(Encoding.UTF8.GetBytes(detailJson)));
var statementIds = ImmutableArray.Create(Guid.NewGuid(), Guid.NewGuid());
var entry = new AdvisoryConflictEntry(
Guid.NewGuid(),
"CVE-2025-4242",
detailJson,
hash,
DateTimeOffset.Parse("2025-10-19T15:00:00Z"),
DateTimeOffset.Parse("2025-10-19T15:05:00Z"),
statementIds);
await _repository.InsertConflictsAsync(new[] { entry }, CancellationToken.None);
var results = await _repository.GetConflictsAsync("CVE-2025-4242", null, CancellationToken.None);
var conflict = Assert.Single(results);
Assert.Equal(entry.CanonicalJson, conflict.CanonicalJson);
Assert.True(entry.StatementIds.SequenceEqual(conflict.StatementIds));
Assert.True(entry.ConflictHash.SequenceEqual(conflict.ConflictHash));
}
private static Advisory CreateSampleAdvisory(string key, string summary)
{
var provenance = new AdvisoryProvenance("nvd", "document", key, DateTimeOffset.Parse("2025-10-18T00:00:00Z"), new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
key,
key,
summary,
"en",
DateTimeOffset.Parse("2025-10-17T00:00:00Z"),
DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
"medium",
exploitKnown: false,
aliases: new[] { key },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private sealed record ConflictPayload(string Type, string Reason);
}

View File

@@ -94,4 +94,50 @@ public sealed class MongoBootstrapperTests : IClassFixture<MongoIntegrationFixtu
await _fixture.Client.DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task InitializeAsync_CreatesAdvisoryEventIndexes()
{
var databaseName = $"concelier-bootstrap-events-{Guid.NewGuid():N}";
var database = _fixture.Client.GetDatabase(databaseName);
try
{
var runner = new MongoMigrationRunner(
database,
Array.Empty<IMongoMigration>(),
NullLogger<MongoMigrationRunner>.Instance,
TimeProvider.System);
var bootstrapper = new MongoBootstrapper(
database,
Options.Create(new MongoStorageOptions()),
NullLogger<MongoBootstrapper>.Instance,
runner);
await bootstrapper.InitializeAsync(CancellationToken.None);
var statementIndexes = await database
.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryStatements)
.Indexes
.ListAsync();
var statementIndexNames = (await statementIndexes.ToListAsync()).Select(x => x["name"].AsString).ToArray();
Assert.Contains("advisory_statements_vulnerability_asof_desc", statementIndexNames);
Assert.Contains("advisory_statements_statementHash_unique", statementIndexNames);
var conflictIndexes = await database
.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryConflicts)
.Indexes
.ListAsync();
var conflictIndexNames = (await conflictIndexes.ToListAsync()).Select(x => x["name"].AsString).ToArray();
Assert.Contains("advisory_conflicts_vulnerability_asof_desc", conflictIndexNames);
Assert.Contains("advisory_conflicts_conflictHash_unique", conflictIndexNames);
}
finally
{
await _fixture.Client.DropDatabaseAsync(databaseName);
}
}
}