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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user