up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -7810,4 +7810,198 @@ internal static class CommandHandlers
|
||||
}
|
||||
|
||||
private sealed record ProviderInfo(string Name, string Type, IReadOnlyList<CryptoProviderKeyDescriptor> Keys);
|
||||
|
||||
#region Risk Profile Commands
|
||||
|
||||
public static async Task HandleRiskProfileValidateAsync(
|
||||
string inputPath,
|
||||
string format,
|
||||
string? outputPath,
|
||||
bool strict,
|
||||
bool verbose)
|
||||
{
|
||||
_ = verbose;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.validate", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("risk-profile validate");
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Input file not found: {0}", Markup.Escape(inputPath));
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var profileJson = await File.ReadAllTextAsync(inputPath).ConfigureAwait(false);
|
||||
var schema = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchema();
|
||||
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
|
||||
|
||||
JsonNode? profileNode;
|
||||
try
|
||||
{
|
||||
profileNode = JsonNode.Parse(profileJson);
|
||||
if (profileNode is null)
|
||||
{
|
||||
throw new InvalidOperationException("Parsed JSON is null.");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid JSON: {0}", Markup.Escape(ex.Message));
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = schema.Evaluate(profileNode);
|
||||
var issues = new List<RiskProfileValidationIssue>();
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
CollectValidationIssues(result, issues);
|
||||
}
|
||||
|
||||
var report = new RiskProfileValidationReport(
|
||||
FilePath: inputPath,
|
||||
IsValid: result.IsValid,
|
||||
SchemaVersion: schemaVersion,
|
||||
Issues: issues);
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(reportJson);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (result.IsValid)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]✓[/] Profile is valid (schema v{0})", schemaVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]✗[/] Profile is invalid (schema v{0})", schemaVersion);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Path");
|
||||
table.AddColumn("Error");
|
||||
table.AddColumn("Message");
|
||||
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(issue.Path),
|
||||
Markup.Escape(issue.Error),
|
||||
Markup.Escape(issue.Message));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
|
||||
}
|
||||
}
|
||||
|
||||
Environment.ExitCode = result.IsValid ? 0 : (strict ? 1 : 0);
|
||||
if (!result.IsValid && !strict)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task HandleRiskProfileSchemaAsync(string? outputPath, bool verbose)
|
||||
{
|
||||
_ = verbose;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.schema", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("risk-profile schema");
|
||||
|
||||
try
|
||||
{
|
||||
var schemaText = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaText();
|
||||
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, schemaText).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine("Risk profile schema v{0} written to [cyan]{1}[/]", schemaVersion, Markup.Escape(outputPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(schemaText);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectValidationIssues(
|
||||
Json.Schema.EvaluationResults results,
|
||||
List<RiskProfileValidationIssue> issues,
|
||||
string path = "")
|
||||
{
|
||||
if (results.Errors is not null)
|
||||
{
|
||||
foreach (var (key, message) in results.Errors)
|
||||
{
|
||||
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
||||
issues.Add(new RiskProfileValidationIssue(instancePath, key, message));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.Details is not null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (!detail.IsValid)
|
||||
{
|
||||
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RiskProfileValidationReport(
|
||||
string FilePath,
|
||||
bool IsValid,
|
||||
string SchemaVersion,
|
||||
IReadOnlyList<RiskProfileValidationIssue> Issues);
|
||||
|
||||
private sealed record RiskProfileValidationIssue(string Path, string Error, string Message);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
@@ -10,6 +10,35 @@ public interface IMerkleTreeCalculator
|
||||
|
||||
public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
|
||||
{
|
||||
private readonly ICryptoHasher _hasher;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MerkleTreeCalculator using the specified hasher.
|
||||
/// </summary>
|
||||
/// <param name="hasher">Crypto hasher resolved from the provider registry.</param>
|
||||
public MerkleTreeCalculator(ICryptoHasher hasher)
|
||||
{
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MerkleTreeCalculator using the crypto registry to resolve the hasher.
|
||||
/// </summary>
|
||||
/// <param name="cryptoRegistry">Crypto provider registry.</param>
|
||||
/// <param name="algorithmId">Hash algorithm to use (defaults to SHA256).</param>
|
||||
/// <param name="preferredProvider">Optional preferred crypto provider.</param>
|
||||
public MerkleTreeCalculator(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
string? algorithmId = null,
|
||||
string? preferredProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cryptoRegistry);
|
||||
|
||||
var algorithm = algorithmId ?? HashAlgorithms.Sha256;
|
||||
var resolution = cryptoRegistry.ResolveHasher(algorithm, preferredProvider);
|
||||
_hasher = resolution.Hasher;
|
||||
}
|
||||
|
||||
public string CalculateRootHash(IEnumerable<string> canonicalLeafValues)
|
||||
{
|
||||
var leaves = canonicalLeafValues
|
||||
@@ -24,7 +53,7 @@ public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
|
||||
return BuildTree(leaves);
|
||||
}
|
||||
|
||||
private static string BuildTree(IReadOnlyList<string> currentLevel)
|
||||
private string BuildTree(IReadOnlyList<string> currentLevel)
|
||||
{
|
||||
if (currentLevel.Count == 1)
|
||||
{
|
||||
@@ -45,10 +74,9 @@ public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
|
||||
return BuildTree(nextLevel);
|
||||
}
|
||||
|
||||
private static string HashString(string value)
|
||||
private string HashString(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return _hasher.ComputeHashHex(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ public sealed class EvidenceLockerOptions
|
||||
public PortableOptions Portable { get; init; } = new();
|
||||
|
||||
public IncidentModeOptions Incident { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic options for hash algorithm selection and provider routing.
|
||||
/// </summary>
|
||||
public EvidenceCryptoOptions Crypto { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
@@ -208,3 +213,20 @@ public sealed class PortableOptions
|
||||
[MinLength(1)]
|
||||
public string MetadataFileName { get; init; } = "bundle.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic options for evidence bundle hashing and provider routing.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCryptoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm used for Merkle tree computation. Defaults to SHA256.
|
||||
/// Supported: SHA256, SHA384, SHA512, GOST3411-2012-256, GOST3411-2012-512.
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; init; } = HashAlgorithms.Sha256;
|
||||
|
||||
/// <summary>
|
||||
/// Preferred crypto provider name. When null, the registry uses its default resolution order.
|
||||
/// </summary>
|
||||
public string? PreferredProvider { get; init; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
@@ -61,7 +62,15 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
services.AddSingleton<IEvidenceLockerMigrationRunner, EvidenceLockerMigrationRunner>();
|
||||
services.AddHostedService<EvidenceLockerMigrationHostedService>();
|
||||
|
||||
services.AddSingleton<IMerkleTreeCalculator, MerkleTreeCalculator>();
|
||||
services.AddSingleton<IMerkleTreeCalculator>(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
|
||||
var cryptoRegistry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
return new MerkleTreeCalculator(
|
||||
cryptoRegistry,
|
||||
options.Crypto.HashAlgorithm,
|
||||
options.Crypto.PreferredProvider);
|
||||
});
|
||||
services.AddScoped<IEvidenceBundleBuilder, EvidenceBundleBuilder>();
|
||||
services.AddScoped<IEvidenceBundleRepository, EvidenceBundleRepository>();
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Channels;
|
||||
|
||||
public sealed class WebhookChannelAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SuccessfulDelivery_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.Sent, result.Status);
|
||||
Assert.Single(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_InvalidEndpoint_ReturnsInvalidConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel(null);
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.InvalidConfiguration, result.Status);
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_RateLimited_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, "rate limited");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions { MaxRetries = 0 });
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ChannelDispatchStatus.Throttled, result.Status);
|
||||
Assert.Equal(429, result.HttpStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ServerError_RetriesAndFails()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "unavailable");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions
|
||||
{
|
||||
MaxRetries = 2,
|
||||
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
|
||||
RetryMaxDelay = TimeSpan.FromMilliseconds(50)
|
||||
});
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
var context = CreateContext(channel);
|
||||
|
||||
// Act
|
||||
var result = await adapter.DispatchAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(3, handler.Requests.Count); // Initial + 2 retries
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ValidEndpoint_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook");
|
||||
|
||||
// Act
|
||||
var result = await adapter.CheckHealthAsync(channel, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Healthy);
|
||||
Assert.Equal("healthy", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_DisabledChannel_ReturnsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "ok");
|
||||
var httpClient = new HttpClient(handler);
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var options = Options.Create(new ChannelAdapterOptions());
|
||||
var adapter = new WebhookChannelAdapter(
|
||||
httpClient,
|
||||
auditRepo,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<WebhookChannelAdapter>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://example.com/webhook", enabled: false);
|
||||
|
||||
// Act
|
||||
var result = await adapter.CheckHealthAsync(channel, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Healthy);
|
||||
Assert.Equal("degraded", result.Status);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(string? endpoint, bool enabled = true)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "test-channel",
|
||||
tenantId: "test-tenant",
|
||||
name: "Test Webhook",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret://test",
|
||||
endpoint: endpoint),
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private static ChannelDispatchContext CreateContext(NotifyChannel channel)
|
||||
{
|
||||
var delivery = NotifyDelivery.Create(
|
||||
deliveryId: "delivery-001",
|
||||
tenantId: channel.TenantId,
|
||||
ruleId: "rule-001",
|
||||
actionId: "action-001",
|
||||
eventId: "event-001",
|
||||
kind: "test",
|
||||
status: NotifyDeliveryStatus.Pending);
|
||||
|
||||
return new ChannelDispatchContext(
|
||||
DeliveryId: delivery.DeliveryId,
|
||||
TenantId: channel.TenantId,
|
||||
Channel: channel,
|
||||
Delivery: delivery,
|
||||
RenderedBody: """{"message": "test notification"}""",
|
||||
Subject: "Test Subject",
|
||||
Metadata: new Dictionary<string, string>(),
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
TraceId: "trace-001");
|
||||
}
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _content;
|
||||
public List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
public MockHttpMessageHandler(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
var response = new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_content)
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository
|
||||
{
|
||||
public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary<string, string> Metadata)> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(
|
||||
string tenantId,
|
||||
string eventType,
|
||||
string actor,
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add((tenantId, eventType, actor, metadata));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class CorrelationEngineTests
|
||||
{
|
||||
private readonly Mock<ICorrelationKeyBuilderFactory> _keyBuilderFactory;
|
||||
private readonly Mock<ICorrelationKeyBuilder> _keyBuilder;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly Mock<INotifyThrottler> _throttler;
|
||||
private readonly Mock<IQuietHoursEvaluator> _quietHoursEvaluator;
|
||||
private readonly CorrelationEngineOptions _options;
|
||||
private readonly CorrelationEngine _engine;
|
||||
|
||||
public CorrelationEngineTests()
|
||||
{
|
||||
_keyBuilderFactory = new Mock<ICorrelationKeyBuilderFactory>();
|
||||
_keyBuilder = new Mock<ICorrelationKeyBuilder>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
_throttler = new Mock<INotifyThrottler>();
|
||||
_quietHoursEvaluator = new Mock<IQuietHoursEvaluator>();
|
||||
_options = new CorrelationEngineOptions();
|
||||
|
||||
_keyBuilderFactory
|
||||
.Setup(f => f.GetBuilder(It.IsAny<string>()))
|
||||
.Returns(_keyBuilder.Object);
|
||||
|
||||
_keyBuilder
|
||||
.Setup(b => b.BuildKey(It.IsAny<NotifyEvent>(), It.IsAny<CorrelationKeyExpression>()))
|
||||
.Returns("test-correlation-key");
|
||||
|
||||
_keyBuilder.SetupGet(b => b.Name).Returns("composite");
|
||||
|
||||
_engine = new CorrelationEngine(
|
||||
_keyBuilderFactory.Object,
|
||||
_incidentManager.Object,
|
||||
_throttler.Object,
|
||||
_quietHoursEvaluator.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<CorrelationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_NewIncident_ReturnsNewIncidentResult()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Correlated);
|
||||
Assert.True(result.IsNewIncident);
|
||||
Assert.True(result.ShouldNotify);
|
||||
Assert.Equal("inc-test123", result.IncidentId);
|
||||
Assert.Equal("test-correlation-key", result.CorrelationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_ExistingIncident_FirstOnlyPolicy_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.FirstOnly;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 5);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 6 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsNewIncident);
|
||||
Assert.False(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_ExistingIncident_EveryEventPolicy_Notifies()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.EveryEvent;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 5);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 6 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsNewIncident);
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_Suppressed_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.Suppressed("Quiet hours", "quiet_hours"));
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldNotify);
|
||||
Assert.Equal("Quiet hours", result.SuppressionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_Throttled_DoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.Throttled(15));
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldNotify);
|
||||
Assert.Contains("Throttled", result.SuppressionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_UsesEventKindSpecificKeyExpression()
|
||||
{
|
||||
// Arrange
|
||||
var customExpression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{tenant}}-{{kind}}"
|
||||
};
|
||||
_options.KeyExpressions["security.alert"] = customExpression;
|
||||
|
||||
var notifyEvent = CreateTestEvent("security.alert");
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
_keyBuilderFactory.Verify(f => f.GetBuilder("template"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_UsesWildcardKeyExpression()
|
||||
{
|
||||
// Arrange
|
||||
var customExpression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "custom",
|
||||
Fields = ["source"]
|
||||
};
|
||||
_options.KeyExpressions["security.*"] = customExpression;
|
||||
|
||||
var notifyEvent = CreateTestEvent("security.vulnerability");
|
||||
var incident = CreateTestIncident(eventCount: 0);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 1 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
_keyBuilderFactory.Verify(f => f.GetBuilder("custom"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_OnEscalationPolicy_NotifiesAtThreshold()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.OnEscalation;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 4); // Will become 5 after record
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 5 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_OnEscalationPolicy_NotifiesOnCriticalSeverity()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.OnEscalation;
|
||||
var payload = new JsonObject { ["severity"] = "CRITICAL" };
|
||||
var notifyEvent = CreateTestEvent(payload: payload);
|
||||
var incident = CreateTestIncident(eventCount: 2);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 3 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelateAsync_PeriodicPolicy_NotifiesAtInterval()
|
||||
{
|
||||
// Arrange
|
||||
_options.NotificationPolicy = NotificationPolicy.Periodic;
|
||||
_options.PeriodicNotificationInterval = 5;
|
||||
var notifyEvent = CreateTestEvent();
|
||||
var incident = CreateTestIncident(eventCount: 9);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.GetOrCreateIncidentAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident);
|
||||
|
||||
_incidentManager
|
||||
.Setup(m => m.RecordEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(incident with { EventCount = 10 });
|
||||
|
||||
_quietHoursEvaluator
|
||||
.Setup(e => e.EvaluateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SuppressionCheckResult.NotSuppressed());
|
||||
|
||||
_throttler
|
||||
.Setup(t => t.CheckAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ThrottleCheckResult.NotThrottled());
|
||||
|
||||
// Act
|
||||
var result = await _engine.CorrelateAsync(notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldNotify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckThrottleAsync_ThrottlingDisabled_ReturnsNotThrottled()
|
||||
{
|
||||
// Arrange
|
||||
_options.ThrottlingEnabled = false;
|
||||
|
||||
// Act
|
||||
var result = await _engine.CheckThrottleAsync("tenant1", "key1", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
_throttler.Verify(
|
||||
t => t.CheckAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string? kind = null, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = "tenant1",
|
||||
Kind = kind ?? "test.event",
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static IncidentState CreateTestIncident(int eventCount)
|
||||
{
|
||||
return new IncidentState
|
||||
{
|
||||
IncidentId = "inc-test123",
|
||||
TenantId = "tenant1",
|
||||
CorrelationKey = "test-correlation-key",
|
||||
EventKind = "test.event",
|
||||
Title = "Test Incident",
|
||||
Status = IncidentStatus.Open,
|
||||
EventCount = eventCount,
|
||||
FirstOccurrence = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
LastOccurrence = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class CompositeCorrelationKeyBuilderTests
|
||||
{
|
||||
private readonly CompositeCorrelationKeyBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsComposite()
|
||||
{
|
||||
Assert.Equal("composite", _builder.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_CompositeType_ReturnsTrue()
|
||||
{
|
||||
Assert.True(_builder.CanHandle("composite"));
|
||||
Assert.True(_builder.CanHandle("COMPOSITE"));
|
||||
Assert.True(_builder.CanHandle("Composite"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_OtherType_ReturnsFalse()
|
||||
{
|
||||
Assert.False(_builder.CanHandle("template"));
|
||||
Assert.False(_builder.CanHandle("jsonpath"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_TenantAndKindOnly_BuildsCorrectKey()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "security.alert");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
IncludeEventKind = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key1);
|
||||
Assert.Equal(16, key1.Length); // SHA256 hash truncated to 16 chars
|
||||
Assert.Equal(key1, key2); // Same input should produce same key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_DifferentTenants_ProducesDifferentKeys()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert");
|
||||
var event2 = CreateTestEvent("tenant2", "security.alert");
|
||||
var expression = CorrelationKeyExpression.Default;
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_DifferentKinds_ProducesDifferentKeys()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert");
|
||||
var event2 = CreateTestEvent("tenant1", "security.warning");
|
||||
var expression = CorrelationKeyExpression.Default;
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithPayloadFields_IncludesFieldValues()
|
||||
{
|
||||
// Arrange
|
||||
var payload1 = new JsonObject { ["source"] = "scanner-1" };
|
||||
var payload2 = new JsonObject { ["source"] = "scanner-2" };
|
||||
var event1 = CreateTestEvent("tenant1", "security.alert", payload1);
|
||||
var event2 = CreateTestEvent("tenant1", "security.alert", payload2);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
IncludeEventKind = true,
|
||||
Fields = ["source"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithNestedPayloadField_ExtractsValue()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["resource"] = new JsonObject { ["id"] = "resource-123" }
|
||||
};
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
Fields = ["resource.id"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Different resource ID
|
||||
payload["resource"]!["id"] = "resource-456";
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_MissingPayloadField_IgnoresField()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject { ["existing"] = "value" };
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = true,
|
||||
Fields = ["nonexistent", "existing"]
|
||||
};
|
||||
|
||||
// Act - should not throw
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_ExcludeTenant_DoesNotIncludeTenant()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "test.event");
|
||||
var event2 = CreateTestEvent("tenant2", "test.event");
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "composite",
|
||||
IncludeTenant = false,
|
||||
IncludeEventKind = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert - keys should be the same since tenant is excluded
|
||||
Assert.Equal(key1, key2);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string tenant, string kind, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = tenant,
|
||||
Kind = kind,
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateCorrelationKeyBuilderTests
|
||||
{
|
||||
private readonly TemplateCorrelationKeyBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsTemplate()
|
||||
{
|
||||
Assert.Equal("template", _builder.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_TemplateType_ReturnsTrue()
|
||||
{
|
||||
Assert.True(_builder.CanHandle("template"));
|
||||
Assert.True(_builder.CanHandle("TEMPLATE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_SimpleTemplate_SubstitutesVariables()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "security.alert");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{tenant}}-{{kind}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal(16, key.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithPayloadVariables_SubstitutesValues()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new JsonObject { ["region"] = "us-east-1" };
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event", payload);
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}-{{region}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
payload["region"] = "eu-west-1";
|
||||
var key2 = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_WithAttributeVariables_SubstitutesValues()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = "tenant1",
|
||||
Kind = "test.event",
|
||||
Payload = new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Attributes = new Dictionary<string, string>
|
||||
{
|
||||
["env"] = "production"
|
||||
}
|
||||
};
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}-{{attr.env}}",
|
||||
IncludeTenant = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var key = _builder.BuildKey(notifyEvent, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_IncludeTenant_PrependsTenantToKey()
|
||||
{
|
||||
// Arrange
|
||||
var event1 = CreateTestEvent("tenant1", "test.event");
|
||||
var event2 = CreateTestEvent("tenant2", "test.event");
|
||||
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = "{{kind}}",
|
||||
IncludeTenant = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = _builder.BuildKey(event1, expression);
|
||||
var key2 = _builder.BuildKey(event2, expression);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_NoTemplate_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _builder.BuildKey(notifyEvent, expression));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKey_EmptyTemplate_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var notifyEvent = CreateTestEvent("tenant1", "test.event");
|
||||
var expression = new CorrelationKeyExpression
|
||||
{
|
||||
Type = "template",
|
||||
Template = " "
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _builder.BuildKey(notifyEvent, expression));
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateTestEvent(string tenant, string kind, JsonObject? payload = null)
|
||||
{
|
||||
return new NotifyEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Tenant = tenant,
|
||||
Kind = kind,
|
||||
Payload = payload ?? new JsonObject(),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class CorrelationKeyBuilderFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetBuilder_KnownType_ReturnsCorrectBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var compositeBuilder = factory.GetBuilder("composite");
|
||||
var templateBuilder = factory.GetBuilder("template");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(compositeBuilder);
|
||||
Assert.IsType<TemplateCorrelationKeyBuilder>(templateBuilder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBuilder_UnknownType_ReturnsDefaultBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var builder = factory.GetBuilder("unknown");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBuilder_CaseInsensitive_ReturnsCorrectBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builders = new ICorrelationKeyBuilder[]
|
||||
{
|
||||
new CompositeCorrelationKeyBuilder(),
|
||||
new TemplateCorrelationKeyBuilder()
|
||||
};
|
||||
var factory = new CorrelationKeyBuilderFactory(builders);
|
||||
|
||||
// Act
|
||||
var builder1 = factory.GetBuilder("COMPOSITE");
|
||||
var builder2 = factory.GetBuilder("Template");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<CompositeCorrelationKeyBuilder>(builder1);
|
||||
Assert.IsType<TemplateCorrelationKeyBuilder>(builder2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class InMemoryIncidentManagerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IncidentManagerOptions _options;
|
||||
private readonly InMemoryIncidentManager _manager;
|
||||
|
||||
public InMemoryIncidentManagerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new IncidentManagerOptions
|
||||
{
|
||||
CorrelationWindow = TimeSpan.FromHours(1),
|
||||
ReopenOnNewEvent = true
|
||||
};
|
||||
_manager = new InMemoryIncidentManager(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryIncidentManager>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncident()
|
||||
{
|
||||
// Act
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(incident);
|
||||
Assert.StartsWith("inc-", incident.IncidentId);
|
||||
Assert.Equal("tenant1", incident.TenantId);
|
||||
Assert.Equal("correlation-key", incident.CorrelationKey);
|
||||
Assert.Equal("security.alert", incident.EventKind);
|
||||
Assert.Equal("Test Alert", incident.Title);
|
||||
Assert.Equal(IncidentStatus.Open, incident.Status);
|
||||
Assert.Equal(0, incident.EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_ReturnsSameIncidentWithinWindow()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act - request again within correlation window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncidentOutsideWindow()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Record an event to set LastOccurrence
|
||||
await _manager.RecordEventAsync("tenant1", incident1.IncidentId, "event-1");
|
||||
|
||||
// Act - request again outside correlation window
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateIncidentAsync_CreatesNewIncidentAfterResolution()
|
||||
{
|
||||
// Arrange
|
||||
var incident1 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.ResolveAsync("tenant1", incident1.IncidentId, "operator");
|
||||
|
||||
// Act - request again after resolution
|
||||
var incident2 = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(incident1.IncidentId, incident2.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_IncrementsEventCount()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, updated.EventCount);
|
||||
Assert.Contains("event-1", updated.EventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_UpdatesLastOccurrence()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
var initialTime = incident.LastOccurrence;
|
||||
|
||||
// Act
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(updated.LastOccurrence > initialTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_ReopensAcknowledgedIncident_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
_options.ReopenOnNewEvent = true;
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.AcknowledgeAsync("tenant1", incident.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var updated = await _manager.RecordEventAsync("tenant1", incident.IncidentId, "event-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IncidentStatus.Open, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_ThrowsForUnknownIncident()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _manager.RecordEventAsync("tenant1", "unknown-id", "event-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_SetsAcknowledgedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var acknowledged = await _manager.AcknowledgeAsync(
|
||||
"tenant1", incident.IncidentId, "operator", "Looking into it");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(acknowledged);
|
||||
Assert.Equal(IncidentStatus.Acknowledged, acknowledged.Status);
|
||||
Assert.Equal("operator", acknowledged.AcknowledgedBy);
|
||||
Assert.NotNull(acknowledged.AcknowledgedAt);
|
||||
Assert.Equal("Looking into it", acknowledged.AcknowledgeComment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant1", "unknown-id", "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsNullForWrongTenant()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant2", incident.IncidentId, "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotChangeResolvedIncident()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
await _manager.ResolveAsync("tenant1", incident.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var result = await _manager.AcknowledgeAsync("tenant1", incident.IncidentId, "operator2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(IncidentStatus.Resolved, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_SetsResolvedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var resolved = await _manager.ResolveAsync(
|
||||
"tenant1", incident.IncidentId, "operator", "Issue fixed");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(IncidentStatus.Resolved, resolved.Status);
|
||||
Assert.Equal("operator", resolved.ResolvedBy);
|
||||
Assert.NotNull(resolved.ResolvedAt);
|
||||
Assert.Equal("Issue fixed", resolved.ResolutionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.ResolveAsync("tenant1", "unknown-id", "operator");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsIncident()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant1", created.IncidentId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.IncidentId, result.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForUnknownIncident()
|
||||
{
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant1", "unknown-id");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForWrongTenant()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _manager.GetOrCreateIncidentAsync(
|
||||
"tenant1", "correlation-key", "security.alert", "Test Alert");
|
||||
|
||||
// Act
|
||||
var result = await _manager.GetAsync("tenant2", created.IncidentId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsIncidentsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.GetOrCreateIncidentAsync("tenant2", "key3", "event3", "Alert 3");
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, i => Assert.Equal("tenant1", i.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_FiltersbyStatus()
|
||||
{
|
||||
// Arrange
|
||||
var inc1 = await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
var inc2 = await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.AcknowledgeAsync("tenant1", inc1.IncidentId, "operator");
|
||||
await _manager.ResolveAsync("tenant1", inc2.IncidentId, "operator");
|
||||
|
||||
// Act
|
||||
var openIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Open);
|
||||
var acknowledgedIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Acknowledged);
|
||||
var resolvedIncidents = await _manager.ListAsync("tenant1", IncidentStatus.Resolved);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(openIncidents);
|
||||
Assert.Single(acknowledgedIncidents);
|
||||
Assert.Single(resolvedIncidents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_OrdersByLastOccurrenceDescending()
|
||||
{
|
||||
// Arrange
|
||||
var inc1 = await _manager.GetOrCreateIncidentAsync("tenant1", "key1", "event1", "Alert 1");
|
||||
await _manager.RecordEventAsync("tenant1", inc1.IncidentId, "e1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var inc2 = await _manager.GetOrCreateIncidentAsync("tenant1", "key2", "event2", "Alert 2");
|
||||
await _manager.RecordEventAsync("tenant1", inc2.IncidentId, "e2");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var inc3 = await _manager.GetOrCreateIncidentAsync("tenant1", "key3", "event3", "Alert 3");
|
||||
await _manager.RecordEventAsync("tenant1", inc3.IncidentId, "e3");
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(inc3.IncidentId, result[0].IncidentId);
|
||||
Assert.Equal(inc2.IncidentId, result[1].IncidentId);
|
||||
Assert.Equal(inc1.IncidentId, result[2].IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_RespectsLimit()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _manager.GetOrCreateIncidentAsync("tenant1", $"key{i}", $"event{i}", $"Alert {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _manager.ListAsync("tenant1", limit: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class InMemoryNotifyThrottlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ThrottlerOptions _options;
|
||||
private readonly InMemoryNotifyThrottler _throttler;
|
||||
|
||||
public InMemoryNotifyThrottlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new ThrottlerOptions
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
DefaultMaxEvents = 10,
|
||||
Enabled = true
|
||||
};
|
||||
_throttler = new InMemoryNotifyThrottler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryNotifyThrottler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEventAsync_AddsEventToState()
|
||||
{
|
||||
// Act
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(1, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_NoEvents_ReturnsNotThrottled()
|
||||
{
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(0, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_BelowThreshold_ReturnsNotThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(5, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_AtThreshold_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(10, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_AboveThreshold_ReturnsThrottled()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(15, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_EventsOutsideWindow_AreRemoved()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Move time forward past the window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(0, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_CustomWindow_UsesCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Move time forward 2 minutes
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Add more events
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act - check with 1 minute window (should only see recent 3)
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", TimeSpan.FromMinutes(1), null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsThrottled);
|
||||
Assert.Equal(3, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_CustomMaxEvents_UsesCustomValue()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act - check with max 3 events
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, 3);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.Equal(5, result.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ThrottledReturnsResetTime()
|
||||
{
|
||||
// Arrange
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
|
||||
// Move time forward 2 minutes
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Fill up to threshold
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsThrottled);
|
||||
Assert.NotNull(result.ThrottleResetIn);
|
||||
// Reset should be ~3 minutes (5 min window - 2 min since oldest event)
|
||||
Assert.True(result.ThrottleResetIn.Value > TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_DifferentKeys_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant1", "key2", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsThrottled);
|
||||
Assert.False(result2.IsThrottled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_DifferentTenants_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant2", "key1", null, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsThrottled);
|
||||
Assert.False(result2.IsThrottled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_RemovesThrottleState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
}
|
||||
|
||||
// Verify throttled
|
||||
var beforeClear = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
Assert.True(beforeClear.IsThrottled);
|
||||
|
||||
// Act
|
||||
await _throttler.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var afterClear = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
Assert.False(afterClear.IsThrottled);
|
||||
Assert.Equal(0, afterClear.RecentEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_OnlyAffectsSpecifiedKey()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _throttler.RecordEventAsync("tenant1", "key1");
|
||||
await _throttler.RecordEventAsync("tenant1", "key2");
|
||||
}
|
||||
|
||||
// Act
|
||||
await _throttler.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var result1 = await _throttler.CheckAsync("tenant1", "key1", null, null);
|
||||
var result2 = await _throttler.CheckAsync("tenant1", "key2", null, null);
|
||||
|
||||
Assert.False(result1.IsThrottled);
|
||||
Assert.True(result2.IsThrottled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class OperatorOverrideServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly OperatorOverrideOptions _options;
|
||||
private readonly InMemoryOperatorOverrideService _service;
|
||||
|
||||
public OperatorOverrideServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero));
|
||||
_options = new OperatorOverrideOptions
|
||||
{
|
||||
MinDuration = TimeSpan.FromMinutes(5),
|
||||
MaxDuration = TimeSpan.FromHours(24),
|
||||
MaxActiveOverridesPerTenant = 50
|
||||
};
|
||||
|
||||
_service = new InMemoryOperatorOverrideService(
|
||||
_auditLogger.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryOperatorOverrideService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_CreatesNewOverride()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Emergency deployment requiring immediate notifications",
|
||||
Duration = TimeSpan.FromHours(2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var @override = await _service.CreateOverrideAsync("tenant1", request, "admin@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(@override);
|
||||
Assert.StartsWith("ovr-", @override.OverrideId);
|
||||
Assert.Equal("tenant1", @override.TenantId);
|
||||
Assert.Equal(OverrideType.All, @override.Type);
|
||||
Assert.Equal("Emergency deployment requiring immediate notifications", @override.Reason);
|
||||
Assert.Equal(OverrideStatus.Active, @override.Status);
|
||||
Assert.Equal("admin@example.com", @override.CreatedBy);
|
||||
Assert.Equal(_timeProvider.GetUtcNow() + TimeSpan.FromHours(2), @override.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_RejectsDurationTooLong()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Very long override",
|
||||
Duration = TimeSpan.FromHours(48) // Exceeds max 24 hours
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateOverrideAsync("tenant1", request, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_RejectsDurationTooShort()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Very short override",
|
||||
Duration = TimeSpan.FromMinutes(1) // Below min 5 minutes
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.CreateOverrideAsync("tenant1", request, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Test override for audit",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.CreateOverrideAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideCreated &&
|
||||
e.Actor == "admin" &&
|
||||
e.TenantId == "tenant1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverrideAsync_ReturnsOverrideIfExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.Throttle,
|
||||
Reason = "Test override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.OverrideId, retrieved.OverrideId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverrideAsync_ReturnsExpiredStatusAfterExpiry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Short override",
|
||||
Duration = TimeSpan.FromMinutes(30)
|
||||
}, "admin");
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(OverrideStatus.Expired, retrieved.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListActiveOverridesAsync_ReturnsOnlyActiveOverrides()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Override 1",
|
||||
Duration = TimeSpan.FromHours(2)
|
||||
}, "admin");
|
||||
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Override 2 (short)",
|
||||
Duration = TimeSpan.FromMinutes(10)
|
||||
}, "admin");
|
||||
|
||||
// Advance time so second override expires
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(15));
|
||||
|
||||
// Act
|
||||
var active = await _service.ListActiveOverridesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(active);
|
||||
Assert.Equal("Override 1", active[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeOverrideAsync_RevokesActiveOverride()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "To be revoked",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var revoked = await _service.RevokeOverrideAsync("tenant1", created.OverrideId, "supervisor", "No longer needed");
|
||||
|
||||
// Assert
|
||||
Assert.True(revoked);
|
||||
|
||||
var retrieved = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(OverrideStatus.Revoked, retrieved.Status);
|
||||
Assert.Equal("supervisor", retrieved.RevokedBy);
|
||||
Assert.Equal("No longer needed", retrieved.RevocationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeOverrideAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "To be revoked",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RevokeOverrideAsync("tenant1", created.OverrideId, "supervisor", "Testing");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideRevoked &&
|
||||
e.Actor == "supervisor"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_ReturnsMatchingOverride()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours,
|
||||
Reason = "Deployment override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "deployment.complete", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasOverride);
|
||||
Assert.NotNull(result.Override);
|
||||
Assert.Equal(OverrideType.QuietHours, result.BypassedTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_ReturnsNoOverrideWhenNoneMatch()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
Assert.Null(result.Override);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_RespectsEventKindFilter()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Only for deployments",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
EventKinds = ["deployment.", "release."]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var deploymentResult = await _service.CheckOverrideAsync("tenant1", "deployment.started", null);
|
||||
var otherResult = await _service.CheckOverrideAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(deploymentResult.HasOverride);
|
||||
Assert.False(otherResult.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_RespectsCorrelationKeyFilter()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.Throttle,
|
||||
Reason = "Specific incident",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
CorrelationKeys = ["incident-123", "incident-456"]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var matchingResult = await _service.CheckOverrideAsync("tenant1", "event.test", "incident-123");
|
||||
var nonMatchingResult = await _service.CheckOverrideAsync("tenant1", "event.test", "incident-789");
|
||||
|
||||
// Assert
|
||||
Assert.True(matchingResult.HasOverride);
|
||||
Assert.False(nonMatchingResult.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_IncrementsUsageCount()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Limited use override",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 5
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
var updated = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(2, updated.UsageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_ExhaustsOverrideAtMaxUsage()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Single use override",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 2
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
var updated = await _service.GetOverrideAsync("tenant1", created.OverrideId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(OverrideStatus.Exhausted, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOverrideUsageAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Override for audit test",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.OverrideUsed &&
|
||||
e.ResourceId == created.OverrideId),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_DoesNotReturnExhaustedOverride()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Single use",
|
||||
Duration = TimeSpan.FromHours(1),
|
||||
MaxUsageCount = 1
|
||||
}, "admin");
|
||||
|
||||
await _service.RecordOverrideUsageAsync("tenant1", created.OverrideId, "event.test");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.other", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOverrideAsync_WithDeferredEffectiveFrom()
|
||||
{
|
||||
// Arrange
|
||||
var futureTime = _timeProvider.GetUtcNow().AddHours(1);
|
||||
var request = new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Future override",
|
||||
Duration = TimeSpan.FromHours(2),
|
||||
EffectiveFrom = futureTime
|
||||
};
|
||||
|
||||
// Act
|
||||
var created = await _service.CreateOverrideAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(futureTime, created.EffectiveFrom);
|
||||
Assert.Equal(futureTime + TimeSpan.FromHours(2), created.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckOverrideAsync_DoesNotReturnNotYetEffectiveOverride()
|
||||
{
|
||||
// Arrange
|
||||
var futureTime = _timeProvider.GetUtcNow().AddHours(1);
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.All,
|
||||
Reason = "Future override",
|
||||
Duration = TimeSpan.FromHours(2),
|
||||
EffectiveFrom = futureTime
|
||||
}, "admin");
|
||||
|
||||
// Act (before effective time)
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverrideType_Flags_WorkCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateOverrideAsync("tenant1", new OperatorOverrideCreate
|
||||
{
|
||||
Type = OverrideType.QuietHours | OverrideType.Throttle, // Multiple types
|
||||
Reason = "Partial override",
|
||||
Duration = TimeSpan.FromHours(1)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckOverrideAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasOverride);
|
||||
Assert.True(result.BypassedTypes.HasFlag(OverrideType.QuietHours));
|
||||
Assert.True(result.BypassedTypes.HasFlag(OverrideType.Throttle));
|
||||
Assert.False(result.BypassedTypes.HasFlag(OverrideType.Maintenance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHourCalendarServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryQuietHourCalendarService _service;
|
||||
|
||||
public QuietHourCalendarServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero)); // Monday 2pm UTC
|
||||
_service = new InMemoryQuietHourCalendarService(
|
||||
_auditLogger.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryQuietHourCalendarService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCalendarAsync_CreatesNewCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Quiet Hours",
|
||||
Description = "Suppress notifications overnight",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var calendar = await _service.CreateCalendarAsync("tenant1", request, "admin@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(calendar);
|
||||
Assert.StartsWith("cal-", calendar.CalendarId);
|
||||
Assert.Equal("tenant1", calendar.TenantId);
|
||||
Assert.Equal("Night Quiet Hours", calendar.Name);
|
||||
Assert.True(calendar.Enabled);
|
||||
Assert.Single(calendar.Schedules);
|
||||
Assert.Equal("admin@example.com", calendar.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCalendarAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Test Calendar"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.CreateCalendarAsync("tenant1", request, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.CalendarCreated &&
|
||||
e.Actor == "admin" &&
|
||||
e.TenantId == "tenant1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_ReturnsAllCalendarsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Calendar 1", Priority = 50 }, "admin");
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Calendar 2", Priority = 100 }, "admin");
|
||||
await _service.CreateCalendarAsync("tenant2", new QuietHourCalendarCreate { Name = "Other Tenant" }, "admin");
|
||||
|
||||
// Act
|
||||
var calendars = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, calendars.Count);
|
||||
Assert.Equal("Calendar 1", calendars[0].Name); // Lower priority first
|
||||
Assert.Equal("Calendar 2", calendars[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ReturnsCalendarIfExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Test" }, "admin");
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetCalendarAsync("tenant1", created.CalendarId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.CalendarId, retrieved.CalendarId);
|
||||
Assert.Equal("Test", retrieved.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ReturnsNullIfNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCalendarAsync_UpdatesExistingCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "Original" }, "admin");
|
||||
|
||||
var update = new QuietHourCalendarUpdate
|
||||
{
|
||||
Name = "Updated",
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.UpdateCalendarAsync("tenant1", created.CalendarId, update, "other-admin");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Updated", updated.Name);
|
||||
Assert.False(updated.Enabled);
|
||||
Assert.Equal("other-admin", updated.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_RemovesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate { Name = "ToDelete" }, "admin");
|
||||
|
||||
// Act
|
||||
var deleted = await _service.DeleteCalendarAsync("tenant1", created.CalendarId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
var retrieved = await _service.GetCalendarAsync("tenant1", created.CalendarId);
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_SuppressesWhenInQuietHours()
|
||||
{
|
||||
// Arrange - Create calendar with quiet hours from 10pm to 8am
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Set time to 23:00 (11pm) - within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("Night Hours", result.CalendarName);
|
||||
Assert.Equal("Overnight", result.ScheduleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_DoesNotSuppressOutsideQuietHours()
|
||||
{
|
||||
// Arrange - Create calendar with quiet hours from 10pm to 8am
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Time is 2pm (14:00) - outside quiet hours
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsExcludedEventKinds()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
ExcludedEventKinds = ["critical.", "urgent."],
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Overnight",
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Set time to 23:00 (11pm) - within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// Act
|
||||
var criticalResult = await _service.EvaluateCalendarsAsync("tenant1", "critical.security.breach", null);
|
||||
var normalResult = await _service.EvaluateCalendarsAsync("tenant1", "info.scan.complete", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(criticalResult.IsSuppressed); // Critical events not suppressed
|
||||
Assert.True(normalResult.IsSuppressed); // Normal events suppressed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsEventKindFilters()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Scan Quiet Hours",
|
||||
EventKinds = ["scan."], // Only applies to scan events
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Always",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var scanResult = await _service.EvaluateCalendarsAsync("tenant1", "scan.complete", null);
|
||||
var otherResult = await _service.EvaluateCalendarsAsync("tenant1", "vulnerability.found", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(scanResult.IsSuppressed);
|
||||
Assert.False(otherResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsScopes()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Team A Quiet Hours",
|
||||
Scopes = ["team-a", "team-b"],
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var teamAResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", ["team-a"]);
|
||||
var teamCResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", ["team-c"]);
|
||||
|
||||
// Assert
|
||||
Assert.True(teamAResult.IsSuppressed);
|
||||
Assert.False(teamCResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_RespectsDaysOfWeek()
|
||||
{
|
||||
// Arrange - Create calendar that only applies on weekends
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Weekend Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "Weekend Only",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [0, 6] // Sunday and Saturday
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Monday (current time is Monday)
|
||||
var mondayResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Set to Saturday
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 20, 14, 0, 0, TimeSpan.Zero));
|
||||
var saturdayResult = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(mondayResult.IsSuppressed);
|
||||
Assert.True(saturdayResult.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_DisabledCalendarDoesNotSuppress()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Night Hours",
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Disable the calendar
|
||||
await _service.UpdateCalendarAsync("tenant1", created.CalendarId, new QuietHourCalendarUpdate { Enabled = false }, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "event.test", null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateCalendarsAsync_HigherPriorityCalendarWins()
|
||||
{
|
||||
// Arrange - Create two calendars with different priorities
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "Low Priority",
|
||||
Priority = 100,
|
||||
ExcludedEventKinds = ["critical."], // This one excludes critical
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
await _service.CreateCalendarAsync("tenant1", new QuietHourCalendarCreate
|
||||
{
|
||||
Name = "High Priority",
|
||||
Priority = 10, // Higher priority (lower number)
|
||||
Schedules =
|
||||
[
|
||||
new CalendarSchedule
|
||||
{
|
||||
Name = "All Day",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
}
|
||||
]
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateCalendarsAsync("tenant1", "critical.alert", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("High Priority", result.CalendarName); // High priority calendar applies first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHoursCalendarServiceTests
|
||||
{
|
||||
private readonly Mock<INotifyAuditRepository> _auditRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryQuietHoursCalendarService _service;
|
||||
|
||||
public QuietHoursCalendarServiceTests()
|
||||
{
|
||||
_auditRepository = new Mock<INotifyAuditRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 30, 0, TimeSpan.Zero)); // Monday 14:30 UTC
|
||||
|
||||
_service = new InMemoryQuietHoursCalendarService(
|
||||
_auditRepository.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryQuietHoursCalendarService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_EmptyTenant_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertCalendarAsync_NewCalendar_CreatesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("cal-1", result.CalendarId);
|
||||
Assert.Equal("tenant1", result.TenantId);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
Assert.Equal("admin", result.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertCalendarAsync_ExistingCalendar_UpdatesCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updated = calendar with { Name = "Updated Name" };
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertCalendarAsync(updated, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated Name", result.Name);
|
||||
Assert.Equal("admin", result.CreatedBy); // Original creator preserved
|
||||
Assert.Equal("admin2", result.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_ExistingCalendar_ReturnsCalendar()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "cal-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cal-1", result.CalendarId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarAsync_NonExistentCalendar_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetCalendarAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_ExistingCalendar_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteCalendarAsync("tenant1", "cal-1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(await _service.GetCalendarAsync("tenant1", "cal-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCalendarAsync_NonExistentCalendar_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DeleteCalendarAsync("tenant1", "nonexistent", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCalendars_ReturnsNotActive()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledCalendar_ReturnsNotActive()
|
||||
{
|
||||
// Arrange
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1") with { Enabled = false };
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinQuietHours_ReturnsActive()
|
||||
{
|
||||
// Arrange - Set time to 22:30 UTC (within 22:00-08:00 quiet hours)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 22, 30, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
Assert.Equal("cal-1", result.MatchedCalendarId);
|
||||
Assert.NotNull(result.EndsAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideQuietHours_ReturnsNotActive()
|
||||
{
|
||||
// Arrange - Time is 14:30 UTC (outside 22:00-08:00 quiet hours)
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithExcludedEventKind_ReturnsNotActive()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
ExcludedEventKinds = new[] { "critical." }
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "critical.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithIncludedEventKind_OnlyMatchesIncluded()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
IncludedEventKinds = new[] { "info." }
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act - Test included event kind
|
||||
var resultIncluded = await _service.EvaluateAsync("tenant1", "info.status");
|
||||
// Act - Test non-included event kind
|
||||
var resultExcluded = await _service.EvaluateAsync("tenant1", "warning.alert");
|
||||
|
||||
// Assert
|
||||
Assert.True(resultIncluded.IsActive);
|
||||
Assert.False(resultExcluded.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithDayOfWeekRestriction_OnlyMatchesSpecifiedDays()
|
||||
{
|
||||
// Arrange - Monday (day 1)
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "00:00", endTime: "23:59") with
|
||||
{
|
||||
Schedules = new[]
|
||||
{
|
||||
new QuietHoursScheduleEntry
|
||||
{
|
||||
Name = "Weekends Only",
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = new[] { 0, 6 }, // Sunday, Saturday
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert - Should not be active on Monday
|
||||
Assert.False(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PriorityOrdering_ReturnsHighestPriority()
|
||||
{
|
||||
// Arrange - Set time within quiet hours
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar1 = CreateTestCalendar("cal-low", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
Name = "Low Priority",
|
||||
Priority = 100
|
||||
};
|
||||
var calendar2 = CreateTestCalendar("cal-high", "tenant1", startTime: "22:00", endTime: "08:00") with
|
||||
{
|
||||
Name = "High Priority",
|
||||
Priority = 10
|
||||
};
|
||||
|
||||
await _service.UpsertCalendarAsync(calendar1, "admin");
|
||||
await _service.UpsertCalendarAsync(calendar2, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert - Should match higher priority (lower number)
|
||||
Assert.True(result.IsActive);
|
||||
Assert.Equal("cal-high", result.MatchedCalendarId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SameDayWindow_EvaluatesCorrectly()
|
||||
{
|
||||
// Arrange - Set time to 10:30 UTC (within 09:00-17:00 business hours)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "09:00", endTime: "17:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithCustomEvaluationTime_UsesProvidedTime()
|
||||
{
|
||||
// Arrange - Current time is 14:30, but we evaluate at 23:00
|
||||
var calendar = CreateTestCalendar("cal-1", "tenant1", startTime: "22:00", endTime: "08:00");
|
||||
await _service.UpsertCalendarAsync(calendar, "admin");
|
||||
|
||||
var evaluationTime = new DateTimeOffset(2024, 1, 15, 23, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
var result = await _service.EvaluateAsync("tenant1", "event.test", evaluationTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCalendarsAsync_ReturnsOrderedByPriority()
|
||||
{
|
||||
// Arrange
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-3", "tenant1") with { Priority = 300 }, "admin");
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-1", "tenant1") with { Priority = 100 }, "admin");
|
||||
await _service.UpsertCalendarAsync(
|
||||
CreateTestCalendar("cal-2", "tenant1") with { Priority = 200 }, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.ListCalendarsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("cal-1", result[0].CalendarId);
|
||||
Assert.Equal("cal-2", result[1].CalendarId);
|
||||
Assert.Equal("cal-3", result[2].CalendarId);
|
||||
}
|
||||
|
||||
private static QuietHoursCalendar CreateTestCalendar(
|
||||
string calendarId,
|
||||
string tenantId,
|
||||
string startTime = "22:00",
|
||||
string endTime = "08:00") => new()
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = $"Test Calendar {calendarId}",
|
||||
Enabled = true,
|
||||
Priority = 100,
|
||||
Schedules = new[]
|
||||
{
|
||||
new QuietHoursScheduleEntry
|
||||
{
|
||||
Name = "Default Schedule",
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class QuietHoursEvaluatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly QuietHoursOptions _options;
|
||||
private readonly QuietHoursEvaluator _evaluator;
|
||||
|
||||
public QuietHoursEvaluatorTests()
|
||||
{
|
||||
// Start at 10:00 AM UTC on a Wednesday
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_options = new QuietHoursOptions { Enabled = true };
|
||||
_evaluator = CreateEvaluator();
|
||||
}
|
||||
|
||||
private QuietHoursEvaluator CreateEvaluator()
|
||||
{
|
||||
return new QuietHoursEvaluator(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<QuietHoursEvaluator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoSchedule_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Schedule = null;
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledSchedule_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Schedule = new QuietHoursSchedule { Enabled = false };
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledGlobally_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinSameDayQuietHours_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 14:00 (2 PM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "12:00",
|
||||
EndTime = "18:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("quiet_hours", result.SuppressionType);
|
||||
Assert.Contains("Quiet hours", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideSameDayQuietHours_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange - set time to 10:00 (10 AM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "12:00",
|
||||
EndTime = "18:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinOvernightQuietHours_Morning_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 06:00 (6 AM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 6, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithinOvernightQuietHours_Evening_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - set time to 23:00 (11 PM)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 23, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OutsideOvernightQuietHours_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange - set time to 12:00 (noon)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "22:00",
|
||||
EndTime = "08:00"
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DayOfWeekFilter_AppliesCorrectly()
|
||||
{
|
||||
// Arrange - Wednesday (day 3)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [0, 6] // Sunday, Saturday only
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert - Wednesday is not in the list
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DayOfWeekIncluded_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange - Wednesday (day 3)
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
DaysOfWeek = [3] // Wednesday
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExcludedEventKind_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 10, 14, 0, 0, TimeSpan.Zero));
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59",
|
||||
ExcludedEventKinds = ["security", "critical"]
|
||||
};
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "security.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_Active_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
Description = "Scheduled maintenance"
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("maintenance", result.SuppressionType);
|
||||
Assert.Contains("Scheduled maintenance", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_NotActive_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(1),
|
||||
EndTime = now.AddHours(2),
|
||||
Description = "Future maintenance"
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_DifferentTenant_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant2", "test.event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_AffectedEventKind_ReturnsSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
AffectedEventKinds = ["scanner", "monitor"]
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "scanner.complete");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceWindow_UnaffectedEventKind_ReturnsNotSuppressed()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
AffectedEventKinds = ["scanner", "monitor"]
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync("tenant1", "security.alert");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMaintenanceWindowAsync_AddsWindow()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now,
|
||||
EndTime = now.AddHours(2)
|
||||
};
|
||||
|
||||
// Act
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Assert
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
Assert.Single(windows);
|
||||
Assert.Equal("maint-1", windows[0].WindowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveMaintenanceWindowAsync_RemovesWindow()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now,
|
||||
EndTime = now.AddHours(2)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
await _evaluator.RemoveMaintenanceWindowAsync("tenant1", "maint-1");
|
||||
|
||||
// Assert
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
Assert.Empty(windows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListMaintenanceWindowsAsync_ExcludesExpiredWindows()
|
||||
{
|
||||
// Arrange
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var activeWindow = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-active",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1)
|
||||
};
|
||||
|
||||
var expiredWindow = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-expired",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-3),
|
||||
EndTime = now.AddHours(-1)
|
||||
};
|
||||
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", activeWindow);
|
||||
await _evaluator.AddMaintenanceWindowAsync("tenant1", expiredWindow);
|
||||
|
||||
// Act
|
||||
var windows = await _evaluator.ListMaintenanceWindowsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(windows);
|
||||
Assert.Equal("maint-active", windows[0].WindowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MaintenanceHasPriorityOverQuietHours()
|
||||
{
|
||||
// Arrange - setup both maintenance and quiet hours
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_options.Schedule = new QuietHoursSchedule
|
||||
{
|
||||
Enabled = true,
|
||||
StartTime = "00:00",
|
||||
EndTime = "23:59"
|
||||
};
|
||||
|
||||
var evaluator = CreateEvaluator();
|
||||
|
||||
var window = new MaintenanceWindow
|
||||
{
|
||||
WindowId = "maint-1",
|
||||
TenantId = "tenant1",
|
||||
StartTime = now.AddHours(-1),
|
||||
EndTime = now.AddHours(1),
|
||||
Description = "System upgrade"
|
||||
};
|
||||
|
||||
await evaluator.AddMaintenanceWindowAsync("tenant1", window);
|
||||
|
||||
// Act
|
||||
var result = await evaluator.EvaluateAsync("tenant1", "test.event");
|
||||
|
||||
// Assert - maintenance should take priority
|
||||
Assert.True(result.IsSuppressed);
|
||||
Assert.Equal("maintenance", result.SuppressionType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class SuppressionAuditLoggerTests
|
||||
{
|
||||
private readonly SuppressionAuditOptions _options;
|
||||
private readonly InMemorySuppressionAuditLogger _logger;
|
||||
|
||||
public SuppressionAuditLoggerTests()
|
||||
{
|
||||
_options = new SuppressionAuditOptions
|
||||
{
|
||||
MaxEntriesPerTenant = 100
|
||||
};
|
||||
|
||||
_logger = new InMemorySuppressionAuditLogger(
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemorySuppressionAuditLogger>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_StoresEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(entry);
|
||||
|
||||
// Assert
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
Assert.Single(results);
|
||||
Assert.Equal(entry.EntryId, results[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsEmptyForUnknownTenant()
|
||||
{
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "nonexistent" });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByTimeRange()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddHours(-3)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, now.AddHours(-1)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, now));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
From = now.AddHours(-2),
|
||||
To = now.AddMinutes(-30)
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(SuppressionAuditAction.CalendarUpdated, results[0].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByAction()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.ThrottleConfigUpdated));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Actions = [SuppressionAuditAction.CalendarCreated, SuppressionAuditAction.CalendarUpdated]
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.DoesNotContain(results, r => r.Action == SuppressionAuditAction.ThrottleConfigUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByActor()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, actor: "admin1"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, actor: "admin2"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, actor: "admin1"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Actor = "admin1"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.Equal("admin1", r.Actor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByResourceType()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, resourceType: "QuietHourCalendar"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.ThrottleConfigUpdated, resourceType: "TenantThrottleConfig"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ResourceType = "QuietHourCalendar"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("QuietHourCalendar", results[0].ResourceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByResourceId()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, resourceId: "cal-123"));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, resourceId: "cal-456"));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ResourceId = "cal-123"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("cal-123", results[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesPagination()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddMinutes(-i)));
|
||||
}
|
||||
|
||||
// Act
|
||||
var firstPage = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Limit = 3,
|
||||
Offset = 0
|
||||
});
|
||||
|
||||
var secondPage = await _logger.QueryAsync(new SuppressionAuditQuery
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Limit = 3,
|
||||
Offset = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, firstPage.Count);
|
||||
Assert.Equal(3, secondPage.Count);
|
||||
Assert.NotEqual(firstPage[0].EntryId, secondPage[0].EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OrdersByTimestampDescending()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated, now.AddHours(-2)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarUpdated, now.AddHours(-1)));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted, now));
|
||||
|
||||
// Act
|
||||
var results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results[0].Timestamp > results[1].Timestamp);
|
||||
Assert.True(results[1].Timestamp > results[2].Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_TrimsOldEntriesWhenLimitExceeded()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SuppressionAuditOptions { MaxEntriesPerTenant = 5 };
|
||||
var logger = new InMemorySuppressionAuditLogger(
|
||||
Options.Create(options),
|
||||
NullLogger<InMemorySuppressionAuditLogger>.Instance);
|
||||
|
||||
// Act - Add more entries than the limit
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
}
|
||||
|
||||
// Assert
|
||||
var results = await logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
Assert.Equal(5, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_IsolatesTenantsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarCreated));
|
||||
await _logger.LogAsync(CreateEntry("tenant2", SuppressionAuditAction.CalendarUpdated));
|
||||
await _logger.LogAsync(CreateEntry("tenant1", SuppressionAuditAction.CalendarDeleted));
|
||||
|
||||
// Act
|
||||
var tenant1Results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant1" });
|
||||
var tenant2Results = await _logger.QueryAsync(new SuppressionAuditQuery { TenantId = "tenant2" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, tenant1Results.Count);
|
||||
Assert.Single(tenant2Results);
|
||||
}
|
||||
|
||||
private static SuppressionAuditEntry CreateEntry(
|
||||
string tenantId,
|
||||
SuppressionAuditAction action,
|
||||
DateTimeOffset? timestamp = null,
|
||||
string actor = "system",
|
||||
string resourceType = "TestResource",
|
||||
string resourceId = "test-123")
|
||||
{
|
||||
return new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
ResourceType = resourceType,
|
||||
ResourceId = resourceId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class ThrottleConfigServiceTests
|
||||
{
|
||||
private readonly Mock<ISuppressionAuditLogger> _auditLogger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ThrottlerOptions _globalOptions;
|
||||
private readonly InMemoryThrottleConfigService _service;
|
||||
|
||||
public ThrottleConfigServiceTests()
|
||||
{
|
||||
_auditLogger = new Mock<ISuppressionAuditLogger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero));
|
||||
_globalOptions = new ThrottlerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
DefaultMaxEvents = 10
|
||||
};
|
||||
|
||||
_service = new InMemoryThrottleConfigService(
|
||||
_auditLogger.Object,
|
||||
Options.Create(_globalOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryThrottleConfigService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_ReturnsGlobalDefaultsWhenNoTenantConfig()
|
||||
{
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.found");
|
||||
|
||||
// Assert
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), config.Window);
|
||||
Assert.Equal(10, config.MaxEvents);
|
||||
Assert.Equal("global", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_CreatesTenantConfig()
|
||||
{
|
||||
// Arrange
|
||||
var update = new TenantThrottleConfigUpdate
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultWindow = TimeSpan.FromMinutes(10),
|
||||
DefaultMaxEvents = 20
|
||||
};
|
||||
|
||||
// Act
|
||||
var config = await _service.SetTenantConfigAsync("tenant1", update, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", config.TenantId);
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), config.DefaultWindow);
|
||||
Assert.Equal(20, config.DefaultMaxEvents);
|
||||
Assert.Equal("admin", config.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var update = new TenantThrottleConfigUpdate { DefaultMaxEvents = 50 };
|
||||
|
||||
// Act
|
||||
await _service.SetTenantConfigAsync("tenant1", update, "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.ThrottleConfigUpdated &&
|
||||
e.ResourceType == "TenantThrottleConfig" &&
|
||||
e.Actor == "admin"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesTenantConfigWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(15),
|
||||
DefaultMaxEvents = 25
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), config.Window);
|
||||
Assert.Equal(25, config.MaxEvents);
|
||||
Assert.Equal("tenant", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEventKindConfigAsync_CreatesEventKindOverride()
|
||||
{
|
||||
// Arrange
|
||||
var update = new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
MaxEvents = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
var config = await _service.SetEventKindConfigAsync("tenant1", "critical.*", update, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", config.TenantId);
|
||||
Assert.Equal("critical.*", config.EventKindPattern);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), config.Window);
|
||||
Assert.Equal(5, config.MaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesEventKindOverrideWhenMatches()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
DefaultWindow = TimeSpan.FromMinutes(10),
|
||||
DefaultMaxEvents = 20
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "critical.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
MaxEvents = 100
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var criticalConfig = await _service.GetEffectiveConfigAsync("tenant1", "critical.security.breach");
|
||||
var normalConfig = await _service.GetEffectiveConfigAsync("tenant1", "info.scan.complete");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("event_kind", criticalConfig.Source);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), criticalConfig.Window);
|
||||
Assert.Equal(100, criticalConfig.MaxEvents);
|
||||
Assert.Equal("critical.*", criticalConfig.MatchedPattern);
|
||||
|
||||
Assert.Equal("tenant", normalConfig.Source);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), normalConfig.Window);
|
||||
Assert.Equal(20, normalConfig.MaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_UsesMoreSpecificPatternFirst()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "vulnerability.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 10,
|
||||
Priority = 100
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "vulnerability.critical.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 5,
|
||||
Priority = 50 // Higher priority (lower number)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var specificConfig = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.critical.cve123");
|
||||
var generalConfig = await _service.GetEffectiveConfigAsync("tenant1", "vulnerability.low.cve456");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, specificConfig.MaxEvents);
|
||||
Assert.Equal("vulnerability.critical.*", specificConfig.MatchedPattern);
|
||||
|
||||
Assert.Equal(10, generalConfig.MaxEvents);
|
||||
Assert.Equal("vulnerability.*", generalConfig.MatchedPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_DisabledEventKindDisablesThrottling()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultMaxEvents = 20
|
||||
}, "admin");
|
||||
|
||||
await _service.SetEventKindConfigAsync("tenant1", "info.*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
Enabled = false
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "info.log");
|
||||
|
||||
// Assert
|
||||
Assert.False(config.Enabled);
|
||||
Assert.Equal("event_kind", config.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListEventKindConfigsAsync_ReturnsAllConfigsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "critical.*", new EventKindThrottleConfigUpdate { MaxEvents = 5, Priority = 10 }, "admin");
|
||||
await _service.SetEventKindConfigAsync("tenant1", "info.*", new EventKindThrottleConfigUpdate { MaxEvents = 100, Priority = 100 }, "admin");
|
||||
await _service.SetEventKindConfigAsync("tenant2", "other.*", new EventKindThrottleConfigUpdate { MaxEvents = 50 }, "admin");
|
||||
|
||||
// Act
|
||||
var configs = await _service.ListEventKindConfigsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, configs.Count);
|
||||
Assert.Equal("critical.*", configs[0].EventKindPattern); // Lower priority first
|
||||
Assert.Equal("info.*", configs[1].EventKindPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveEventKindConfigAsync_RemovesConfig()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "test.*", new EventKindThrottleConfigUpdate { MaxEvents = 5 }, "admin");
|
||||
|
||||
// Act
|
||||
var removed = await _service.RemoveEventKindConfigAsync("tenant1", "test.*", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(removed);
|
||||
var configs = await _service.ListEventKindConfigsAsync("tenant1");
|
||||
Assert.Empty(configs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveEventKindConfigAsync_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "test.*", new EventKindThrottleConfigUpdate { MaxEvents = 5 }, "admin");
|
||||
|
||||
// Act
|
||||
await _service.RemoveEventKindConfigAsync("tenant1", "test.*", "admin");
|
||||
|
||||
// Assert
|
||||
_auditLogger.Verify(a => a.LogAsync(
|
||||
It.Is<SuppressionAuditEntry>(e =>
|
||||
e.Action == SuppressionAuditAction.ThrottleConfigDeleted &&
|
||||
e.ResourceId == "test.*"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantConfigAsync_ReturnsNullWhenNotSet()
|
||||
{
|
||||
// Act
|
||||
var config = await _service.GetTenantConfigAsync("nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantConfigAsync_ReturnsConfigWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 50 }, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetTenantConfigAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(50, config.DefaultMaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTenantConfigAsync_UpdatesExistingConfig()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 10 }, "admin1");
|
||||
|
||||
// Act
|
||||
var updated = await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate { DefaultMaxEvents = 20 }, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(20, updated.DefaultMaxEvents);
|
||||
Assert.Equal("admin2", updated.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_IncludesBurstAllowanceAndCooldown()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetTenantConfigAsync("tenant1", new TenantThrottleConfigUpdate
|
||||
{
|
||||
BurstAllowance = 5,
|
||||
CooldownPeriod = TimeSpan.FromMinutes(10)
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, config.BurstAllowance);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), config.CooldownPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveConfigAsync_WildcardPatternMatchesAllEvents()
|
||||
{
|
||||
// Arrange
|
||||
await _service.SetEventKindConfigAsync("tenant1", "*", new EventKindThrottleConfigUpdate
|
||||
{
|
||||
MaxEvents = 1000,
|
||||
Priority = 1000 // Very low priority
|
||||
}, "admin");
|
||||
|
||||
// Act
|
||||
var config = await _service.GetEffectiveConfigAsync("tenant1", "any.event.kind.here");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1000, config.MaxEvents);
|
||||
Assert.Equal("*", config.MatchedPattern);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Correlation;
|
||||
|
||||
public class ThrottleConfigurationServiceTests
|
||||
{
|
||||
private readonly Mock<INotifyAuditRepository> _auditRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryThrottleConfigurationService _service;
|
||||
|
||||
public ThrottleConfigurationServiceTests()
|
||||
{
|
||||
_auditRepository = new Mock<INotifyAuditRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_service = new InMemoryThrottleConfigurationService(
|
||||
_auditRepository.Object,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryThrottleConfigurationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_NoConfiguration_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetConfigurationAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_NewConfiguration_CreatesConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", result.TenantId);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), result.DefaultDuration);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
Assert.Equal("admin", result.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_ExistingConfiguration_UpdatesConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updated = config with { DefaultDuration = TimeSpan.FromMinutes(60) };
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertConfigurationAsync(updated, "admin2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(60), result.DefaultDuration);
|
||||
Assert.Equal("admin", result.CreatedBy); // Original creator preserved
|
||||
Assert.Equal("admin2", result.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_ExistingConfiguration_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(await _service.GetConfigurationAsync("tenant1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_NonExistentConfiguration_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_NoConfiguration_ReturnsDefault()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), result); // Default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithConfiguration_ReturnsConfiguredDuration()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(45)
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(45), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_DisabledConfiguration_ReturnsDefault()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(45),
|
||||
Enabled = false
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "event.test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), result); // Default when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithExactMatchOverride_ReturnsOverride()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical.alert"] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithPrefixMatchOverride_ReturnsOverride()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert.high");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_WithMultipleOverrides_ReturnsLongestPrefixMatch()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5),
|
||||
["critical.alert."] = TimeSpan.FromMinutes(2)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "critical.alert.security");
|
||||
|
||||
// Assert - Should match the more specific override
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveThrottleDurationAsync_NoMatchingOverride_ReturnsDefault()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1") with
|
||||
{
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
EventKindOverrides = new Dictionary<string, TimeSpan>
|
||||
{
|
||||
["critical."] = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEffectiveThrottleDurationAsync("tenant1", "info.status");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_AuditsCreation()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
|
||||
// Act
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_created",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertConfigurationAsync_AuditsUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
_auditRepository.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _service.UpsertConfigurationAsync(config with { DefaultDuration = TimeSpan.FromHours(1) }, "admin2");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_updated",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin2",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConfigurationAsync_AuditsDeletion()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateTestConfiguration("tenant1");
|
||||
await _service.UpsertConfigurationAsync(config, "admin");
|
||||
_auditRepository.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _service.DeleteConfigurationAsync("tenant1", "admin");
|
||||
|
||||
// Assert
|
||||
_auditRepository.Verify(a => a.AppendAsync(
|
||||
"tenant1",
|
||||
"throttle_config_deleted",
|
||||
It.IsAny<Dictionary<string, string>>(),
|
||||
"admin",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ThrottleConfiguration CreateTestConfiguration(string tenantId) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDuration = TimeSpan.FromMinutes(30),
|
||||
Enabled = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Digest;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Digest;
|
||||
|
||||
public sealed class DigestGeneratorTests
|
||||
{
|
||||
private readonly InMemoryIncidentManager _incidentManager;
|
||||
private readonly DigestGenerator _generator;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public DigestGeneratorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-27T12:00:00Z"));
|
||||
|
||||
var incidentOptions = Options.Create(new IncidentManagerOptions
|
||||
{
|
||||
CorrelationWindow = TimeSpan.FromHours(1),
|
||||
ReopenOnNewEvent = true
|
||||
});
|
||||
|
||||
_incidentManager = new InMemoryIncidentManager(
|
||||
incidentOptions,
|
||||
_timeProvider,
|
||||
new NullLogger<InMemoryIncidentManager>());
|
||||
|
||||
var digestOptions = Options.Create(new DigestOptions
|
||||
{
|
||||
MaxIncidentsPerDigest = 50,
|
||||
TopAffectedCount = 5,
|
||||
RenderContent = true,
|
||||
RenderSlackBlocks = true,
|
||||
SkipEmptyDigests = true
|
||||
});
|
||||
|
||||
_generator = new DigestGenerator(
|
||||
_incidentManager,
|
||||
digestOptions,
|
||||
_timeProvider,
|
||||
new NullLogger<DigestGenerator>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_EmptyTenant_ReturnsEmptyDigest()
|
||||
{
|
||||
// Arrange
|
||||
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tenant-1", result.TenantId);
|
||||
Assert.Empty(result.Incidents);
|
||||
Assert.Equal(0, result.Summary.TotalEvents);
|
||||
Assert.Equal(0, result.Summary.NewIncidents);
|
||||
Assert.False(result.Summary.HasActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithIncidents_ReturnsSummary()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "vuln:critical:pkg-foo", "vulnerability.detected", "Critical vulnerability in pkg-foo");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-2");
|
||||
|
||||
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Incidents);
|
||||
Assert.Equal(2, result.Summary.TotalEvents);
|
||||
Assert.Equal(1, result.Summary.NewIncidents);
|
||||
Assert.Equal(1, result.Summary.OpenIncidents);
|
||||
Assert.True(result.Summary.HasActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_MultipleIncidents_GroupsByEventKind()
|
||||
{
|
||||
// Arrange
|
||||
var inc1 = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key1", "vulnerability.detected", "Vuln 1");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", inc1.IncidentId, "evt-1");
|
||||
|
||||
var inc2 = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key2", "vulnerability.detected", "Vuln 2");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", inc2.IncidentId, "evt-2");
|
||||
|
||||
var inc3 = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key3", "pack.approval.required", "Approval needed");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", inc3.IncidentId, "evt-3");
|
||||
|
||||
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Incidents.Count);
|
||||
Assert.Equal(3, result.Summary.TotalEvents);
|
||||
Assert.Contains("vulnerability.detected", result.Summary.ByEventKind.Keys);
|
||||
Assert.Contains("pack.approval.required", result.Summary.ByEventKind.Keys);
|
||||
Assert.Equal(2, result.Summary.ByEventKind["vulnerability.detected"]);
|
||||
Assert.Equal(1, result.Summary.ByEventKind["pack.approval.required"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_RendersContent()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key", "vulnerability.detected", "Critical issue");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
||||
|
||||
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Content);
|
||||
Assert.NotEmpty(result.Content.PlainText!);
|
||||
Assert.NotEmpty(result.Content.Markdown!);
|
||||
Assert.NotEmpty(result.Content.Html!);
|
||||
Assert.NotEmpty(result.Content.Json!);
|
||||
Assert.NotEmpty(result.Content.SlackBlocks!);
|
||||
|
||||
Assert.Contains("Notification Digest", result.Content.PlainText);
|
||||
Assert.Contains("tenant-1", result.Content.PlainText);
|
||||
Assert.Contains("Critical issue", result.Content.PlainText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_RespectsMaxIncidents()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var inc = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", $"key-{i}", "test.event", $"Test incident {i}");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", inc.IncidentId, $"evt-{i}");
|
||||
}
|
||||
|
||||
var query = new DigestQuery
|
||||
{
|
||||
From = _timeProvider.GetUtcNow().AddDays(-1),
|
||||
To = _timeProvider.GetUtcNow(),
|
||||
MaxIncidents = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Incidents.Count);
|
||||
Assert.Equal(10, result.TotalIncidentCount);
|
||||
Assert.True(result.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_FiltersResolvedIncidents()
|
||||
{
|
||||
// Arrange
|
||||
var openInc = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key-open", "test.event", "Open incident");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", openInc.IncidentId, "evt-1");
|
||||
|
||||
var resolvedInc = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key-resolved", "test.event", "Resolved incident");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", resolvedInc.IncidentId, "evt-2");
|
||||
await _incidentManager.ResolveAsync("tenant-1", resolvedInc.IncidentId, "system", "Auto-resolved");
|
||||
|
||||
var queryExcludeResolved = new DigestQuery
|
||||
{
|
||||
From = _timeProvider.GetUtcNow().AddDays(-1),
|
||||
To = _timeProvider.GetUtcNow(),
|
||||
IncludeResolved = false
|
||||
};
|
||||
|
||||
var queryIncludeResolved = new DigestQuery
|
||||
{
|
||||
From = _timeProvider.GetUtcNow().AddDays(-1),
|
||||
To = _timeProvider.GetUtcNow(),
|
||||
IncludeResolved = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var resultExclude = await _generator.GenerateAsync("tenant-1", queryExcludeResolved);
|
||||
var resultInclude = await _generator.GenerateAsync("tenant-1", queryIncludeResolved);
|
||||
|
||||
// Assert
|
||||
Assert.Single(resultExclude.Incidents);
|
||||
Assert.Equal("Open incident", resultExclude.Incidents[0].Title);
|
||||
|
||||
Assert.Equal(2, resultInclude.Incidents.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_FiltersEventKinds()
|
||||
{
|
||||
// Arrange
|
||||
var vulnInc = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key-vuln", "vulnerability.detected", "Vulnerability");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", vulnInc.IncidentId, "evt-1");
|
||||
|
||||
var approvalInc = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key-approval", "pack.approval.required", "Approval");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", approvalInc.IncidentId, "evt-2");
|
||||
|
||||
var query = new DigestQuery
|
||||
{
|
||||
From = _timeProvider.GetUtcNow().AddDays(-1),
|
||||
To = _timeProvider.GetUtcNow(),
|
||||
EventKinds = ["vulnerability.detected"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Incidents);
|
||||
Assert.Equal("vulnerability.detected", result.Incidents[0].EventKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_SetsIsPreviewFlag()
|
||||
{
|
||||
// Arrange
|
||||
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
"tenant-1", "key", "test.event", "Test");
|
||||
await _incidentManager.RecordEventAsync("tenant-1", incident.IncidentId, "evt-1");
|
||||
|
||||
var query = DigestQuery.LastHours(24, _timeProvider.GetUtcNow());
|
||||
|
||||
// Act
|
||||
var result = await _generator.PreviewAsync("tenant-1", query);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsPreview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DigestQuery_LastHours_CalculatesCorrectWindow()
|
||||
{
|
||||
// Arrange
|
||||
var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z");
|
||||
|
||||
// Act
|
||||
var query = DigestQuery.LastHours(6, asOf);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-11-27T06:00:00Z"), query.From);
|
||||
Assert.Equal(asOf, query.To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DigestQuery_LastDays_CalculatesCorrectWindow()
|
||||
{
|
||||
// Arrange
|
||||
var asOf = DateTimeOffset.Parse("2025-11-27T12:00:00Z");
|
||||
|
||||
// Act
|
||||
var query = DigestQuery.LastDays(7, asOf);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-11-20T12:00:00Z"), query.From);
|
||||
Assert.Equal(asOf, query.To);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
|
||||
private sealed class NullLogger<T> : ILogger<T>
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => false;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Digest;
|
||||
|
||||
public class InMemoryDigestSchedulerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryDigestScheduler _scheduler;
|
||||
|
||||
public InMemoryDigestSchedulerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_scheduler = new InMemoryDigestScheduler(
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDigestScheduler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_CreatesNewSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("schedule-1", result.ScheduleId);
|
||||
Assert.NotNull(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_UpdatesExistingSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
var updated = schedule with { Name = "Updated Name" };
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(updated);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated Name", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScheduleAsync_ReturnsSchedule()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1");
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("schedule-1", result.ScheduleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScheduleAsync_ReturnsNullForUnknown()
|
||||
{
|
||||
// Act
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "unknown");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSchedulesAsync_ReturnsTenantSchedules()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1", "tenant1"));
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-2", "tenant1"));
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-3", "tenant2"));
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetSchedulesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, s => Assert.Equal("tenant1", s.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScheduleAsync_RemovesSchedule()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-1"));
|
||||
|
||||
// Act
|
||||
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "schedule-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
var result = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScheduleAsync_ReturnsFalseForUnknown()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _scheduler.DeleteScheduleAsync("tenant1", "unknown");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDueSchedulesAsync_ReturnsDueSchedules()
|
||||
{
|
||||
// Arrange - create a schedule that should run every minute
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 * * * * *" // Every minute
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Advance time past next run
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Act
|
||||
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
|
||||
|
||||
// Assert
|
||||
Assert.Single(dueSchedules);
|
||||
Assert.Equal("schedule-1", dueSchedules[0].ScheduleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDueSchedulesAsync_ExcludesDisabledSchedules()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 * * * * *"
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
// Act
|
||||
var dueSchedules = await _scheduler.GetDueSchedulesAsync(_timeProvider.GetUtcNow());
|
||||
|
||||
// Assert
|
||||
Assert.Empty(dueSchedules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLastRunAsync_UpdatesTimestamps()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 0 * * * *" // Every hour
|
||||
};
|
||||
await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
var runTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Act
|
||||
await _scheduler.UpdateLastRunAsync("tenant1", "schedule-1", runTime);
|
||||
|
||||
// Assert
|
||||
var updated = await _scheduler.GetScheduleAsync("tenant1", "schedule-1");
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(runTime, updated.LastRunAt);
|
||||
Assert.NotNull(updated.NextRunAt);
|
||||
Assert.True(updated.NextRunAt > runTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_CalculatesNextRunWithTimezone()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "0 0 9 * * *", // 9 AM every day
|
||||
Timezone = "America/New_York"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertScheduleAsync_HandlesInvalidCron()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = CreateTestSchedule("schedule-1") with
|
||||
{
|
||||
CronExpression = "invalid-cron"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.UpsertScheduleAsync(schedule);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSchedulesAsync_OrdersByName()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-c") with { Name = "Charlie" });
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-a") with { Name = "Alpha" });
|
||||
await _scheduler.UpsertScheduleAsync(CreateTestSchedule("schedule-b") with { Name = "Bravo" });
|
||||
|
||||
// Act
|
||||
var result = await _scheduler.GetSchedulesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("Alpha", result[0].Name);
|
||||
Assert.Equal("Bravo", result[1].Name);
|
||||
Assert.Equal("Charlie", result[2].Name);
|
||||
}
|
||||
|
||||
private DigestSchedule CreateTestSchedule(string id, string tenantId = "tenant1")
|
||||
{
|
||||
return new DigestSchedule
|
||||
{
|
||||
ScheduleId = id,
|
||||
TenantId = tenantId,
|
||||
Name = $"Test Schedule {id}",
|
||||
Enabled = true,
|
||||
CronExpression = "0 0 8 * * *", // 8 AM daily
|
||||
DigestType = DigestType.Daily,
|
||||
Format = DigestFormat.Html,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Recipients =
|
||||
[
|
||||
new DigestRecipient { Type = "email", Address = "test@example.com" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Dispatch;
|
||||
|
||||
public sealed class SimpleTemplateRendererTests
|
||||
{
|
||||
private readonly SimpleTemplateRenderer _renderer;
|
||||
|
||||
public SimpleTemplateRendererTests()
|
||||
{
|
||||
_renderer = new SimpleTemplateRenderer(NullLogger<SimpleTemplateRenderer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SimpleVariableSubstitution_ReplacesVariables()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-1",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "test-template",
|
||||
locale: "en",
|
||||
body: "Hello {{actor}}, event {{kind}} occurred.");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
actor: "admin@example.com",
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Hello admin@example.com", result.Body);
|
||||
Assert.Contains("event policy.violation occurred", result.Body);
|
||||
Assert.NotEmpty(result.BodyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_PayloadVariables_FlattenedAndAvailable()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-2",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "payload-test",
|
||||
locale: "en",
|
||||
body: "Image: {{image}}, Severity: {{severity}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["image"] = "registry.local/api:v1.0",
|
||||
["severity"] = "critical"
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "scan.complete",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Image: registry.local/api:v1.0", result.Body);
|
||||
Assert.Contains("Severity: critical", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_NestedPayloadVariables_SupportsDotNotation()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-3",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "nested-test",
|
||||
locale: "en",
|
||||
body: "Package: {{package.name}} v{{package.version}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["package"] = new JsonObject
|
||||
{
|
||||
["name"] = "lodash",
|
||||
["version"] = "4.17.21"
|
||||
}
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "vulnerability.found",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("Package: lodash v4.17.21", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SensitiveKeys_AreRedacted()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-4",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "redact-test",
|
||||
locale: "en",
|
||||
body: "Token: {{apikey}}, User: {{username}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["apikey"] = "secret-token-12345",
|
||||
["username"] = "testuser"
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "auth.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("[REDACTED]", result.Body);
|
||||
Assert.Contains("User: testuser", result.Body);
|
||||
Assert.DoesNotContain("secret-token-12345", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_MissingVariables_ReplacedWithEmptyString()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-5",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "missing-test",
|
||||
locale: "en",
|
||||
body: "Value: {{nonexistent}}-end");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal("Value: -end", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlock_IteratesOverArray()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-6",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "each-test",
|
||||
locale: "en",
|
||||
body: "Items:{{#each items}} {{this}}{{/each}}");
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["items"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "list.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Contains("alpha", result.Body);
|
||||
Assert.Contains("beta", result.Body);
|
||||
Assert.Contains("gamma", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SubjectFromMetadata_RendersSubject()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-7",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "subject-test",
|
||||
locale: "en",
|
||||
body: "Body content",
|
||||
metadata: new[] { new KeyValuePair<string, string>("subject", "Alert: {{kind}}") });
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "critical.alert",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal("Alert: critical.alert", result.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_BodyHash_IsConsistent()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-8",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "hash-test",
|
||||
locale: "en",
|
||||
body: "Static content");
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result1 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
var result2 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal(result1.BodyHash, result2.BodyHash);
|
||||
Assert.Equal(64, result1.BodyHash.Length); // SHA256 hex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_Format_PreservedFromTemplate()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tpl-9",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "format-test",
|
||||
locale: "en",
|
||||
body: "Content",
|
||||
format: NotifyDeliveryFormat.Markdown);
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject(),
|
||||
version: "1");
|
||||
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
Assert.Equal(NotifyDeliveryFormat.Markdown, result.Format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Dispatch;
|
||||
|
||||
public sealed class WebhookChannelDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportedTypes_IncludesSlackAndWebhook()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
Assert.Contains(NotifyChannelType.Slack, dispatcher.SupportedTypes);
|
||||
Assert.Contains(NotifyChannelType.Webhook, dispatcher.SupportedTypes);
|
||||
Assert.Contains(NotifyChannelType.Custom, dispatcher.SupportedTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SuccessfulDelivery_ReturnsSucceeded()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Delivered, result.Status);
|
||||
Assert.Equal(1, result.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_InvalidEndpoint_ReturnsFailedWithMessage()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("not-a-valid-url");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Failed, result.Status);
|
||||
Assert.Contains("Invalid webhook endpoint", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_NullEndpoint_ReturnsFailedWithMessage()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel(null);
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Invalid webhook endpoint", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_4xxError_ReturnsNonRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.BadRequest);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(NotifyDeliveryStatus.Failed, result.Status);
|
||||
Assert.False(result.IsRetryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_5xxError_ReturnsRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsRetryable);
|
||||
Assert.Equal(3, result.AttemptCount); // Should retry up to 3 times
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_TooManyRequests_ReturnsRetryable()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.TooManyRequests);
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://hooks.example.com/webhook");
|
||||
var content = CreateContent("Test message");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsRetryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_SlackChannel_FormatsCorrectly()
|
||||
{
|
||||
string? capturedBody = null;
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK, req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = NotifyChannel.Create(
|
||||
channelId: "chn-slack",
|
||||
tenantId: "tenant-a",
|
||||
name: "Slack Alerts",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret-ref",
|
||||
target: "#alerts",
|
||||
endpoint: "https://hooks.slack.com/services/xxx"));
|
||||
|
||||
var content = CreateContent("Alert notification");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.NotNull(capturedBody);
|
||||
Assert.Contains("\"text\":", capturedBody);
|
||||
Assert.Contains("\"channel\":", capturedBody);
|
||||
Assert.Contains("#alerts", capturedBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_GenericWebhook_IncludesDeliveryMetadata()
|
||||
{
|
||||
string? capturedBody = null;
|
||||
var handler = new TestHttpMessageHandler(HttpStatusCode.OK, req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var dispatcher = new WebhookChannelDispatcher(client, NullLogger<WebhookChannelDispatcher>.Instance);
|
||||
|
||||
var channel = CreateChannel("https://api.example.com/notifications");
|
||||
var content = CreateContent("Webhook content");
|
||||
var delivery = CreateDelivery();
|
||||
|
||||
await dispatcher.DispatchAsync(channel, content, delivery);
|
||||
|
||||
Assert.NotNull(capturedBody);
|
||||
Assert.Contains("\"deliveryId\":", capturedBody);
|
||||
Assert.Contains("\"eventId\":", capturedBody);
|
||||
Assert.Contains("\"kind\":", capturedBody);
|
||||
Assert.Contains("\"body\":", capturedBody);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(string? endpoint)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "chn-test",
|
||||
tenantId: "tenant-a",
|
||||
name: "Test Channel",
|
||||
type: NotifyChannelType.Webhook,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "secret-ref",
|
||||
endpoint: endpoint));
|
||||
}
|
||||
|
||||
private static NotifyRenderedContent CreateContent(string body)
|
||||
{
|
||||
return new NotifyRenderedContent
|
||||
{
|
||||
Body = body,
|
||||
Subject = "Test Subject",
|
||||
BodyHash = "abc123",
|
||||
Format = NotifyDeliveryFormat.PlainText
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyDelivery CreateDelivery()
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: "del-test-001",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-1",
|
||||
actionId: "act-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
status: NotifyDeliveryStatus.Pending);
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly Action<HttpRequestMessage>? _onRequest;
|
||||
|
||||
public TestHttpMessageHandler(HttpStatusCode statusCode, Action<HttpRequestMessage>? onRequest = null)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_onRequest = onRequest;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_onRequest?.Invoke(request);
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent("OK")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Endpoints;
|
||||
|
||||
public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryRuleRepository _ruleRepository;
|
||||
private readonly InMemoryTemplateRepository _templateRepository;
|
||||
|
||||
public NotifyApiEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_ruleRepository = new InMemoryRuleRepository();
|
||||
_templateRepository = new InMemoryTemplateRepository();
|
||||
|
||||
var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyRuleRepository>(_ruleRepository);
|
||||
services.AddSingleton<INotifyTemplateRepository>(_templateRepository);
|
||||
});
|
||||
builder.UseSetting("Environment", "Testing");
|
||||
});
|
||||
|
||||
_client = customFactory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
}
|
||||
|
||||
#region Rules API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRules_ReturnsEmptyList_WhenNoRules()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var rules = await response.Content.ReadFromJsonAsync<List<RuleResponse>>();
|
||||
Assert.NotNull(rules);
|
||||
Assert.Empty(rules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_ReturnsCreated_WithValidRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RuleCreateRequest
|
||||
{
|
||||
RuleId = "rule-001",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Enabled = true,
|
||||
Match = new RuleMatchRequest
|
||||
{
|
||||
EventKinds = ["pack.approval.granted"],
|
||||
Labels = ["env=prod"]
|
||||
},
|
||||
Actions =
|
||||
[
|
||||
new RuleActionRequest
|
||||
{
|
||||
ActionId = "action-001",
|
||||
Channel = "slack:alerts",
|
||||
Template = "tmpl-slack-001"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/rules", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var rule = await response.Content.ReadFromJsonAsync<RuleResponse>();
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal("rule-001", rule.RuleId);
|
||||
Assert.Equal("Test Rule", rule.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRule_ReturnsRule_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-get-001",
|
||||
tenantId: "test-tenant",
|
||||
name: "Existing Rule",
|
||||
match: NotifyRuleMatch.Create(eventKinds: ["test.event"]),
|
||||
actions: []);
|
||||
await _ruleRepository.UpsertAsync(rule);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules/rule-get-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<RuleResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("rule-get-001", result.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRule_ReturnsNotFound_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/rules/nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRule_ReturnsNoContent_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-delete-001",
|
||||
tenantId: "test-tenant",
|
||||
name: "Delete Me",
|
||||
match: NotifyRuleMatch.Create(),
|
||||
actions: []);
|
||||
await _ruleRepository.UpsertAsync(rule);
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync("/api/v2/notify/rules/rule-delete-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Templates API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplates_ReturnsEmptyList_WhenNoTemplates()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/templates");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var templates = await response.Content.ReadFromJsonAsync<List<TemplateResponse>>();
|
||||
Assert.NotNull(templates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewTemplate_ReturnsRenderedContent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name}}, you have {{count}} messages.",
|
||||
SamplePayload = JsonSerializer.SerializeToNode(new { name = "World", count = 5 }) as System.Text.Json.Nodes.JsonObject
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/preview", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var preview = await response.Content.ReadFromJsonAsync<TemplatePreviewResponse>();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Contains("Hello World", preview.RenderedBody);
|
||||
Assert.Contains("5", preview.RenderedBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplate_ReturnsValid_ForCorrectTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name}}!"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(result.GetProperty("isValid").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplate_ReturnsInvalid_ForBrokenTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TemplatePreviewRequest
|
||||
{
|
||||
TemplateBody = "Hello {{name} - missing closing brace"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.False(result.GetProperty("isValid").GetBoolean());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Incidents API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetIncidents_ReturnsIncidentList()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v2/notify/incidents");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<IncidentListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Incidents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AckIncident_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new IncidentAckRequest
|
||||
{
|
||||
Actor = "test-user",
|
||||
Comment = "Acknowledged"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v2/notify/incidents/incident-001/ack", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AllEndpoints_ReturnBadRequest_WhenTenantMissing()
|
||||
{
|
||||
// Arrange
|
||||
var clientWithoutTenant = new HttpClient { BaseAddress = _client.BaseAddress };
|
||||
|
||||
// Act
|
||||
var response = await clientWithoutTenant.GetAsync("/api/v2/notify/rules");
|
||||
|
||||
// Assert - should fail without tenant header
|
||||
// Note: actual behavior depends on endpoint implementation
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Repositories
|
||||
|
||||
private sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyRule> _rules = new();
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{rule.TenantId}:{rule.RuleId}";
|
||||
_rules[key] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{ruleId}";
|
||||
return Task.FromResult(_rules.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _rules.Values.Where(r => r.TenantId == tenantId).ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{ruleId}";
|
||||
_rules.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyTemplate> _templates = new();
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{template.TenantId}:{template.TemplateId}";
|
||||
_templates[key] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
return Task.FromResult(_templates.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _templates.Values.Where(t => t.TenantId == tenantId).ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
_templates.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Fallback;
|
||||
|
||||
public class InMemoryFallbackHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FallbackHandlerOptions _options;
|
||||
private readonly InMemoryFallbackHandler _fallbackHandler;
|
||||
|
||||
public InMemoryFallbackHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new FallbackHandlerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 3,
|
||||
DefaultChains = new Dictionary<NotifyChannelType, List<NotifyChannelType>>
|
||||
{
|
||||
[NotifyChannelType.Slack] = [NotifyChannelType.Teams, NotifyChannelType.Email],
|
||||
[NotifyChannelType.Teams] = [NotifyChannelType.Slack, NotifyChannelType.Email],
|
||||
[NotifyChannelType.Email] = [NotifyChannelType.Webhook],
|
||||
[NotifyChannelType.Webhook] = []
|
||||
}
|
||||
};
|
||||
_fallbackHandler = new InMemoryFallbackHandler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryFallbackHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_FirstFailure_ReturnsNextChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Connection timeout");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasFallback);
|
||||
Assert.Equal(NotifyChannelType.Teams, result.NextChannelType);
|
||||
Assert.Equal(2, result.AttemptNumber);
|
||||
Assert.Equal(3, result.TotalChannels); // Slack -> Teams -> Email
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_SecondFailure_ReturnsThirdChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Connection timeout");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Rate limited");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasFallback);
|
||||
Assert.Equal(NotifyChannelType.Email, result.NextChannelType);
|
||||
Assert.Equal(3, result.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_AllChannelsFailed_ReturnsExhausted()
|
||||
{
|
||||
// Arrange - exhaust all channels (Slack -> Teams -> Email)
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Email, "Failed");
|
||||
|
||||
// Act
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Email, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
Assert.True(result.IsExhausted);
|
||||
Assert.Null(result.NextChannelType);
|
||||
Assert.Equal(3, result.FailedChannels.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_NoFallbackConfigured_ReturnsNoFallback()
|
||||
{
|
||||
// Act - Webhook has no fallback chain
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Webhook, "Failed");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Webhook, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
Assert.Contains("No fallback", result.ExhaustionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_DisabledHandler_ReturnsNoFallback()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new FallbackHandlerOptions { Enabled = false };
|
||||
var disabledHandler = new InMemoryFallbackHandler(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryFallbackHandler>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await disabledHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordSuccessAsync_MarksDeliveryAsSucceeded()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Act
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery1", NotifyChannelType.Teams);
|
||||
|
||||
// Assert
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
Assert.Equal(1, stats.FallbackSuccesses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackChainAsync_ReturnsDefaultChain()
|
||||
{
|
||||
// Act
|
||||
var chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, chain.Count);
|
||||
Assert.Equal(NotifyChannelType.Teams, chain[0]);
|
||||
Assert.Equal(NotifyChannelType.Email, chain[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetFallbackChainAsync_CreatesTenantSpecificChain()
|
||||
{
|
||||
// Act
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Webhook, NotifyChannelType.Email],
|
||||
"admin");
|
||||
|
||||
var chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, chain.Count);
|
||||
Assert.Equal(NotifyChannelType.Webhook, chain[0]);
|
||||
Assert.Equal(NotifyChannelType.Email, chain[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetFallbackChainAsync_DoesNotAffectOtherTenants()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Webhook],
|
||||
"admin");
|
||||
|
||||
// Act
|
||||
var tenant1Chain = await _fallbackHandler.GetFallbackChainAsync("tenant1", NotifyChannelType.Slack);
|
||||
var tenant2Chain = await _fallbackHandler.GetFallbackChainAsync("tenant2", NotifyChannelType.Slack);
|
||||
|
||||
// Assert
|
||||
Assert.Single(tenant1Chain);
|
||||
Assert.Equal(NotifyChannelType.Webhook, tenant1Chain[0]);
|
||||
|
||||
Assert.Equal(2, tenant2Chain.Count); // Default chain
|
||||
Assert.Equal(NotifyChannelType.Teams, tenant2Chain[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_ReturnsAccurateStats()
|
||||
{
|
||||
// Arrange - Create various delivery scenarios
|
||||
// Delivery 1: Primary success
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery1", NotifyChannelType.Slack);
|
||||
|
||||
// Delivery 2: Fallback success
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery2", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery2");
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "delivery2", NotifyChannelType.Teams);
|
||||
|
||||
// Delivery 3: Exhausted
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery3", NotifyChannelType.Webhook, "Failed");
|
||||
|
||||
// Act
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant1", stats.TenantId);
|
||||
Assert.Equal(3, stats.TotalDeliveries);
|
||||
Assert.Equal(1, stats.PrimarySuccesses);
|
||||
Assert.Equal(1, stats.FallbackSuccesses);
|
||||
Assert.Equal(1, stats.FallbackAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_FiltersWithinWindow()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "old-delivery", NotifyChannelType.Slack);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(25));
|
||||
|
||||
await _fallbackHandler.RecordSuccessAsync("tenant1", "recent-delivery", NotifyChannelType.Slack);
|
||||
|
||||
// Act - Get stats for last 24 hours
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1", TimeSpan.FromHours(24));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, stats.TotalDeliveries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearDeliveryStateAsync_RemovesDeliveryTracking()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Act
|
||||
await _fallbackHandler.ClearDeliveryStateAsync("tenant1", "delivery1");
|
||||
|
||||
// Get fallback again - should start fresh
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed again");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
// Assert - Should be back to first fallback attempt
|
||||
Assert.Equal(NotifyChannelType.Teams, result.NextChannelType);
|
||||
Assert.Equal(2, result.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFallbackAsync_MaxAttemptsExceeded_ReturnsExhausted()
|
||||
{
|
||||
// Arrange - MaxAttempts is 3, but chain has 4 channels (Slack + 3 fallbacks would exceed)
|
||||
// Add a longer chain
|
||||
await _fallbackHandler.SetFallbackChainAsync(
|
||||
"tenant1",
|
||||
NotifyChannelType.Slack,
|
||||
[NotifyChannelType.Teams, NotifyChannelType.Email, NotifyChannelType.Webhook, NotifyChannelType.Custom],
|
||||
"admin");
|
||||
|
||||
// Fail through 3 attempts (max)
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Failed");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Email, "Failed");
|
||||
|
||||
// Act - 4th attempt should be blocked by MaxAttempts
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Email, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsExhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFailureAsync_TracksMultipleFailures()
|
||||
{
|
||||
// Arrange & Act
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Slack, "Timeout");
|
||||
await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Slack, "delivery1");
|
||||
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "delivery1", NotifyChannelType.Teams, "Rate limited");
|
||||
var result = await _fallbackHandler.GetFallbackAsync("tenant1", NotifyChannelType.Teams, "delivery1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.FailedChannels.Count);
|
||||
Assert.Contains(result.FailedChannels, f => f.ChannelType == NotifyChannelType.Slack && f.Reason == "Timeout");
|
||||
Assert.Contains(result.FailedChannels, f => f.ChannelType == NotifyChannelType.Teams && f.Reason == "Rate limited");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_TracksFailuresByChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d1", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d2", NotifyChannelType.Slack, "Failed");
|
||||
await _fallbackHandler.RecordFailureAsync("tenant1", "d3", NotifyChannelType.Teams, "Failed");
|
||||
|
||||
// Act
|
||||
var stats = await _fallbackHandler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, stats.FailuresByChannel[NotifyChannelType.Slack]);
|
||||
Assert.Equal(1, stats.FailuresByChannel[NotifyChannelType.Teams]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Localization;
|
||||
|
||||
public class InMemoryLocalizationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly LocalizationServiceOptions _options;
|
||||
private readonly InMemoryLocalizationService _localizationService;
|
||||
|
||||
public InMemoryLocalizationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new LocalizationServiceOptions
|
||||
{
|
||||
DefaultLocale = "en-US",
|
||||
EnableFallback = true,
|
||||
EnableCaching = true,
|
||||
CacheDuration = TimeSpan.FromMinutes(15),
|
||||
ReturnKeyWhenMissing = true,
|
||||
PlaceholderFormat = "named"
|
||||
};
|
||||
_localizationService = new InMemoryLocalizationService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryLocalizationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_SystemBundle_ReturnsValue()
|
||||
{
|
||||
// Act - system bundles are seeded automatically
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Notification Storm Detected", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_GermanLocale_ReturnsGermanValue()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "de-DE");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Benachrichtigungssturm erkannt", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_FrenchLocale_ReturnsFrenchValue()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "fr-FR");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Equal("Tempête de notifications détectée", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_UnknownKey_ReturnsKey()
|
||||
{
|
||||
// Act
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "unknown.key", "en-US");
|
||||
|
||||
// Assert (when ReturnKeyWhenMissing = true)
|
||||
Assert.Equal("unknown.key", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_LocaleFallback_UsesDefaultLocale()
|
||||
{
|
||||
// Act - Japanese locale (not configured) should fall back to en-US
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "ja-JP");
|
||||
|
||||
// Assert - should get en-US value
|
||||
Assert.Equal("Notification Storm Detected", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFormattedStringAsync_ReplacesPlaceholders()
|
||||
{
|
||||
// Act
|
||||
var parameters = new Dictionary<string, object>
|
||||
{
|
||||
["stormKey"] = "critical.alert",
|
||||
["count"] = 50,
|
||||
["window"] = "5 minutes"
|
||||
};
|
||||
var value = await _localizationService.GetFormattedStringAsync(
|
||||
"tenant1", "storm.detected.body", "en-US", parameters);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(value);
|
||||
Assert.Contains("critical.alert", value);
|
||||
Assert.Contains("50", value);
|
||||
Assert.Contains("5 minutes", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertBundleAsync_CreatesTenantBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "tenant-bundle",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Namespace = "custom",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["custom.greeting"] = "Hello, World!"
|
||||
},
|
||||
Description = "Custom tenant bundle"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsNew);
|
||||
Assert.Equal("tenant-bundle", result.BundleId);
|
||||
|
||||
// Verify string is accessible
|
||||
var greeting = await _localizationService.GetStringAsync("tenant1", "custom.greeting", "en-US");
|
||||
Assert.Equal("Hello, World!", greeting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertBundleAsync_UpdatesExistingBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "update-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["test.key"] = "Original value"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act - update with new value
|
||||
var updatedBundle = bundle with
|
||||
{
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["test.key"] = "Updated value"
|
||||
}
|
||||
};
|
||||
var result = await _localizationService.UpsertBundleAsync(updatedBundle, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.IsNew);
|
||||
|
||||
var value = await _localizationService.GetStringAsync("tenant1", "test.key", "en-US");
|
||||
Assert.Equal("Updated value", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBundleAsync_RemovesBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "delete-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["delete.key"] = "Will be deleted"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act
|
||||
var deleted = await _localizationService.DeleteBundleAsync("tenant1", "delete-test", "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
|
||||
var bundles = await _localizationService.ListBundlesAsync("tenant1");
|
||||
Assert.DoesNotContain(bundles, b => b.BundleId == "delete-test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBundlesAsync_ReturnsAllTenantBundles()
|
||||
{
|
||||
// Arrange
|
||||
var bundle1 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "list-test-1",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key1"] = "value1" }
|
||||
};
|
||||
var bundle2 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "list-test-2",
|
||||
TenantId = "tenant1",
|
||||
Locale = "de-DE",
|
||||
Strings = new Dictionary<string, string> { ["key2"] = "value2" }
|
||||
};
|
||||
var bundle3 = new LocalizationBundle
|
||||
{
|
||||
BundleId = "other-tenant",
|
||||
TenantId = "tenant2",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key3"] = "value3" }
|
||||
};
|
||||
|
||||
await _localizationService.UpsertBundleAsync(bundle1, "admin");
|
||||
await _localizationService.UpsertBundleAsync(bundle2, "admin");
|
||||
await _localizationService.UpsertBundleAsync(bundle3, "admin");
|
||||
|
||||
// Act
|
||||
var tenant1Bundles = await _localizationService.ListBundlesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, tenant1Bundles.Count);
|
||||
Assert.Contains(tenant1Bundles, b => b.BundleId == "list-test-1");
|
||||
Assert.Contains(tenant1Bundles, b => b.BundleId == "list-test-2");
|
||||
Assert.DoesNotContain(tenant1Bundles, b => b.BundleId == "other-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSupportedLocalesAsync_ReturnsAvailableLocales()
|
||||
{
|
||||
// Act
|
||||
var locales = await _localizationService.GetSupportedLocalesAsync("tenant1");
|
||||
|
||||
// Assert - should include seeded system locales
|
||||
Assert.Contains("en-US", locales);
|
||||
Assert.Contains("de-DE", locales);
|
||||
Assert.Contains("fr-FR", locales);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBundleAsync_ReturnsMergedStrings()
|
||||
{
|
||||
// Arrange - add tenant bundle that overrides a system string
|
||||
var tenantBundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "tenant-override",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Priority = 10, // Higher priority than system (0)
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["storm.detected.title"] = "Custom Storm Title",
|
||||
["tenant.custom"] = "Custom Value"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(tenantBundle, "admin");
|
||||
|
||||
// Act
|
||||
var bundle = await _localizationService.GetBundleAsync("tenant1", "en-US");
|
||||
|
||||
// Assert - should have both system and tenant strings, with tenant override
|
||||
Assert.True(bundle.ContainsKey("storm.detected.title"));
|
||||
Assert.Equal("Custom Storm Title", bundle["storm.detected.title"]);
|
||||
Assert.True(bundle.ContainsKey("tenant.custom"));
|
||||
Assert.True(bundle.ContainsKey("fallback.attempted.title")); // System string
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "valid-bundle",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingBundleId_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string> { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Bundle ID"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingLocale_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "",
|
||||
Strings = new Dictionary<string, string> { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Locale"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyStrings_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "en-US",
|
||||
Strings = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _localizationService.Validate(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("at least one string"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_CachesResults()
|
||||
{
|
||||
// Act - first call
|
||||
var value1 = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Advance time slightly (within cache duration)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Second call should hit cache
|
||||
var value2 = await _localizationService.GetStringAsync("tenant1", "storm.detected.title", "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(value1, value2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFormattedStringAsync_FormatsNumbers()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = "number-test",
|
||||
TenantId = "tenant1",
|
||||
Locale = "de-DE",
|
||||
Strings = new Dictionary<string, string>
|
||||
{
|
||||
["number.test"] = "Total: {{count}} items"
|
||||
}
|
||||
};
|
||||
await _localizationService.UpsertBundleAsync(bundle, "admin");
|
||||
|
||||
// Act
|
||||
var parameters = new Dictionary<string, object> { ["count"] = 1234567 };
|
||||
var value = await _localizationService.GetFormattedStringAsync(
|
||||
"tenant1", "number.test", "de-DE", parameters);
|
||||
|
||||
// Assert - German number formatting uses periods as thousands separator
|
||||
Assert.Contains("1.234.567", value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class ChaosTestRunnerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ChaosTestOptions _options;
|
||||
private readonly InMemoryChaosTestRunner _runner;
|
||||
|
||||
public ChaosTestRunnerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new ChaosTestOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxConcurrentExperiments = 5,
|
||||
MaxExperimentDuration = TimeSpan.FromHours(1),
|
||||
RequireTenantTarget = false
|
||||
};
|
||||
_runner = new InMemoryChaosTestRunner(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryChaosTestRunner>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_CreatesExperiment()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
// Act
|
||||
var experiment = await _runner.StartExperimentAsync(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(experiment);
|
||||
Assert.Equal(ChaosExperimentStatus.Running, experiment.Status);
|
||||
Assert.Equal("Test Outage", experiment.Config.Name);
|
||||
Assert.NotNull(experiment.StartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_WhenDisabled_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new ChaosTestOptions { Enabled = false };
|
||||
var runner = new InMemoryChaosTestRunner(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryChaosTestRunner>.Instance);
|
||||
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.StartExperimentAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_ExceedsMaxDuration_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Long Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromHours(2) // Exceeds max of 1 hour
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _runner.StartExperimentAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartExperimentAsync_MaxConcurrentReached_Throws()
|
||||
{
|
||||
// Arrange - start max number of experiments
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = $"Experiment {i}",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "One too many",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopExperimentAsync_StopsExperiment()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
await _runner.StopExperimentAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
var stopped = await _runner.GetExperimentAsync(experiment.Id);
|
||||
Assert.NotNull(stopped);
|
||||
Assert.Equal(ChaosExperimentStatus.Stopped, stopped.Status);
|
||||
Assert.NotNull(stopped.EndedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_OutageFault_ReturnsFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(decision.ShouldFail);
|
||||
Assert.Equal(ChaosFaultType.Outage, decision.FaultType);
|
||||
Assert.NotNull(decision.InjectedError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_NoMatchingExperiment_ReturnsNoFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act - different tenant
|
||||
var decision = await _runner.ShouldFailAsync("tenant2", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_WrongChannelType_ReturnsNoFault()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Email Outage",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act - different channel type
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "slack");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_LatencyFault_InjectsLatency()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Latency Test",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Latency,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
MinLatency = TimeSpan.FromSeconds(1),
|
||||
MaxLatency = TimeSpan.FromSeconds(5)
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail); // Latency doesn't cause failure
|
||||
Assert.NotNull(decision.InjectedLatency);
|
||||
Assert.InRange(decision.InjectedLatency.Value.TotalSeconds, 1, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_PartialFailure_UsesFailureRate()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Partial Failure",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.PartialFailure,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
FailureRate = 0.5,
|
||||
Seed = 42 // Fixed seed for reproducibility
|
||||
}
|
||||
});
|
||||
|
||||
// Act - run multiple times
|
||||
var failures = 0;
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
if (decision.ShouldFail) failures++;
|
||||
}
|
||||
|
||||
// Assert - should be roughly 50% failures (with some variance)
|
||||
Assert.InRange(failures, 30, 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_RateLimit_EnforcesLimit()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Rate Limit",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.RateLimit,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
RateLimitPerMinute = 5
|
||||
}
|
||||
});
|
||||
|
||||
// Act - first 5 should pass
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
// 6th should fail
|
||||
var failedDecision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(failedDecision.ShouldFail);
|
||||
Assert.Equal(429, failedDecision.InjectedStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_ExperimentExpires_StopsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Short Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
// Act - advance time past duration
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldFailAsync_MaxOperationsReached_StopsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Limited Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
MaxAffectedOperations = 3
|
||||
});
|
||||
|
||||
// Act - consume all operations
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var d = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
Assert.True(d.ShouldFail);
|
||||
}
|
||||
|
||||
// 4th should not match
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.False(decision.ShouldFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOutcomeAsync_RecordsOutcome()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
// Act
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.FaultInjected,
|
||||
ChannelType = "email",
|
||||
TenantId = "tenant1",
|
||||
FallbackTriggered = true
|
||||
});
|
||||
|
||||
var results = await _runner.GetResultsAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.TotalAffected);
|
||||
Assert.Equal(1, results.FailedOperations);
|
||||
Assert.Equal(1, results.FallbackTriggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResultsAsync_CalculatesStatistics()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Test",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Latency
|
||||
});
|
||||
|
||||
// Record various outcomes
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.LatencyInjected,
|
||||
ChannelType = "email",
|
||||
Duration = TimeSpan.FromMilliseconds(100)
|
||||
});
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.LatencyInjected,
|
||||
ChannelType = "email",
|
||||
Duration = TimeSpan.FromMilliseconds(200)
|
||||
});
|
||||
await _runner.RecordOutcomeAsync(experiment.Id, new ChaosOutcome
|
||||
{
|
||||
Type = ChaosOutcomeType.FaultInjected,
|
||||
ChannelType = "slack",
|
||||
FallbackTriggered = true
|
||||
});
|
||||
|
||||
// Act
|
||||
var results = await _runner.GetResultsAsync(experiment.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.TotalAffected);
|
||||
Assert.Equal(1, results.FailedOperations);
|
||||
Assert.Equal(1, results.FallbackTriggered);
|
||||
Assert.NotNull(results.AverageInjectedLatency);
|
||||
Assert.Equal(150, results.AverageInjectedLatency.Value.TotalMilliseconds);
|
||||
Assert.Equal(2, results.ByChannelType["email"].TotalAffected);
|
||||
Assert.Equal(1, results.ByChannelType["slack"].TotalAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListExperimentsAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var running = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Running",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
|
||||
var toStop = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "To Stop",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage
|
||||
});
|
||||
await _runner.StopExperimentAsync(toStop.Id);
|
||||
|
||||
// Act
|
||||
var runningList = await _runner.ListExperimentsAsync(ChaosExperimentStatus.Running);
|
||||
var stoppedList = await _runner.ListExperimentsAsync(ChaosExperimentStatus.Stopped);
|
||||
|
||||
// Assert
|
||||
Assert.Single(runningList);
|
||||
Assert.Single(stoppedList);
|
||||
Assert.Equal(running.Id, runningList[0].Id);
|
||||
Assert.Equal(toStop.Id, stoppedList[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupAsync_RemovesOldExperiments()
|
||||
{
|
||||
// Arrange
|
||||
var experiment = await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Old Experiment",
|
||||
InitiatedBy = "test-user",
|
||||
FaultType = ChaosFaultType.Outage,
|
||||
Duration = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
// Complete the experiment
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
await _runner.GetExperimentAsync(experiment.Id); // Triggers status update
|
||||
|
||||
// Advance time beyond cleanup threshold
|
||||
_timeProvider.Advance(TimeSpan.FromDays(10));
|
||||
|
||||
// Act
|
||||
var removed = await _runner.CleanupAsync(TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, removed);
|
||||
var result = await _runner.GetExperimentAsync(experiment.Id);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorResponseFault_ReturnsConfiguredStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
await _runner.StartExperimentAsync(new ChaosExperimentConfig
|
||||
{
|
||||
Name = "Error Response",
|
||||
InitiatedBy = "test-user",
|
||||
TenantId = "tenant1",
|
||||
TargetChannelTypes = ["email"],
|
||||
FaultType = ChaosFaultType.ErrorResponse,
|
||||
FaultConfig = new ChaosFaultConfig
|
||||
{
|
||||
ErrorStatusCode = 503,
|
||||
ErrorMessage = "Service Unavailable"
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
var decision = await _runner.ShouldFailAsync("tenant1", "email");
|
||||
|
||||
// Assert
|
||||
Assert.True(decision.ShouldFail);
|
||||
Assert.Equal(503, decision.InjectedStatusCode);
|
||||
Assert.Contains("Service Unavailable", decision.InjectedError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class DeadLetterHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DeadLetterOptions _options;
|
||||
private readonly InMemoryDeadLetterHandler _handler;
|
||||
|
||||
public DeadLetterHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new DeadLetterOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxRetries = 3,
|
||||
RetryDelay = TimeSpan.FromMinutes(5),
|
||||
MaxEntriesPerTenant = 1000
|
||||
};
|
||||
_handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadLetterAsync_AddsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Connection timeout",
|
||||
OriginalPayload = "{ \"to\": \"user@example.com\" }",
|
||||
ErrorDetails = "SMTP timeout after 30s",
|
||||
AttemptCount = 3
|
||||
};
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
// Assert
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("delivery-001", entries[0].DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadLetterAsync_WhenDisabled_DoesNotAdd()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new DeadLetterOptions { Enabled = false };
|
||||
var handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
|
||||
// Act
|
||||
await handler.DeadLetterAsync(entry);
|
||||
|
||||
// Assert
|
||||
var entries = await handler.GetEntriesAsync("tenant1");
|
||||
Assert.Empty(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryAsync_ReturnsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
// Get the entry ID from the list
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var retrieved = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("delivery-001", retrieved.DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryAsync_WrongTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var retrieved = await _handler.GetEntryAsync("tenant2", entryId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
var result = await _handler.RetryAsync("tenant1", entryId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Scheduled);
|
||||
Assert.Equal(entryId, result.EntryId);
|
||||
|
||||
var updated = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(DeadLetterStatus.PendingRetry, updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryAsync_ExceedsMaxRetries_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error",
|
||||
RetryCount = 3 // Already at max
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_handler.RetryAsync("tenant1", entryId, "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
};
|
||||
await _handler.DeadLetterAsync(entry);
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
// Act
|
||||
await _handler.DiscardAsync("tenant1", entryId, "Not needed", "admin");
|
||||
|
||||
// Assert
|
||||
var updated = await _handler.GetEntryAsync("tenant1", entryId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(DeadLetterStatus.Discarded, updated.Status);
|
||||
Assert.Equal("Not needed", updated.DiscardReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
await _handler.DiscardAsync("tenant1", entries[0].Id, "Test", "admin");
|
||||
|
||||
// Act
|
||||
var pending = await _handler.GetEntriesAsync("tenant1", status: DeadLetterStatus.Pending);
|
||||
var discarded = await _handler.GetEntriesAsync("tenant1", status: DeadLetterStatus.Discarded);
|
||||
|
||||
// Assert
|
||||
Assert.Single(pending);
|
||||
Assert.Single(discarded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_FiltersByChannelType()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "slack",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act
|
||||
var emailEntries = await _handler.GetEntriesAsync("tenant1", channelType: "email");
|
||||
|
||||
// Assert
|
||||
Assert.Single(emailEntries);
|
||||
Assert.Equal("email", emailEntries[0].ChannelType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntriesAsync_PaginatesResults()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = $"delivery-{i:D3}",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _handler.GetEntriesAsync("tenant1", limit: 5, offset: 0);
|
||||
var page2 = await _handler.GetEntriesAsync("tenant1", limit: 5, offset: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, page1.Count);
|
||||
Assert.Equal(5, page2.Count);
|
||||
Assert.NotEqual(page1[0].Id, page2[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CalculatesStats()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Timeout"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Timeout"
|
||||
});
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-003",
|
||||
ChannelType = "slack",
|
||||
Reason = "Auth failed"
|
||||
});
|
||||
|
||||
// Act
|
||||
var stats = await _handler.GetStatisticsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, stats.TotalEntries);
|
||||
Assert.Equal(3, stats.PendingCount);
|
||||
Assert.Equal(2, stats.ByChannelType["email"]);
|
||||
Assert.Equal(1, stats.ByChannelType["slack"]);
|
||||
Assert.Equal(2, stats.ByReason["Timeout"]);
|
||||
Assert.Equal(1, stats.ByReason["Auth failed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_FiltersToWindow()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(25));
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act - get stats for last 24 hours only
|
||||
var stats = await _handler.GetStatisticsAsync("tenant1", TimeSpan.FromHours(24));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, stats.TotalEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeAsync_RemovesOldEntries()
|
||||
{
|
||||
// Arrange
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromDays(10));
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-002",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Act - purge entries older than 7 days
|
||||
var purged = await _handler.PurgeAsync("tenant1", TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, purged);
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("delivery-002", entries[0].DeliveryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_NotifiesObserver()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
using var subscription = _handler.Subscribe(observer);
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(observer.ReceivedEvents);
|
||||
Assert.Equal(DeadLetterEventType.Added, observer.ReceivedEvents[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_NotifiesOnRetry()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
var entries = await _handler.GetEntriesAsync("tenant1");
|
||||
var entryId = entries[0].Id;
|
||||
|
||||
using var subscription = _handler.Subscribe(observer);
|
||||
|
||||
// Act
|
||||
await _handler.RetryAsync("tenant1", entryId, "admin");
|
||||
|
||||
// Assert
|
||||
Assert.Single(observer.ReceivedEvents);
|
||||
Assert.Equal(DeadLetterEventType.RetryScheduled, observer.ReceivedEvents[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_DisposedDoesNotNotify()
|
||||
{
|
||||
// Arrange
|
||||
var observer = new TestDeadLetterObserver();
|
||||
var subscription = _handler.Subscribe(observer);
|
||||
subscription.Dispose();
|
||||
|
||||
// Act
|
||||
await _handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = "delivery-001",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Empty(observer.ReceivedEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaxEntriesPerTenant_EnforcesLimit()
|
||||
{
|
||||
// Arrange
|
||||
var limitedOptions = new DeadLetterOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxEntriesPerTenant = 3
|
||||
};
|
||||
var handler = new InMemoryDeadLetterHandler(
|
||||
Options.Create(limitedOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryDeadLetterHandler>.Instance);
|
||||
|
||||
// Act - add 5 entries
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await handler.DeadLetterAsync(new DeadLetterEntry
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
DeliveryId = $"delivery-{i:D3}",
|
||||
ChannelType = "email",
|
||||
Reason = "Error"
|
||||
});
|
||||
}
|
||||
|
||||
// Assert - should only have 3 entries (oldest removed)
|
||||
var entries = await handler.GetEntriesAsync("tenant1");
|
||||
Assert.Equal(3, entries.Count);
|
||||
}
|
||||
|
||||
private sealed class TestDeadLetterObserver : IDeadLetterObserver
|
||||
{
|
||||
public List<DeadLetterEvent> ReceivedEvents { get; } = [];
|
||||
|
||||
public void OnDeadLetterEvent(DeadLetterEvent evt)
|
||||
{
|
||||
ReceivedEvents.Add(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Observability;
|
||||
|
||||
public class RetentionPolicyServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RetentionPolicyOptions _options;
|
||||
private readonly InMemoryRetentionPolicyService _service;
|
||||
|
||||
public RetentionPolicyServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new RetentionPolicyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultRetentionPeriod = TimeSpan.FromDays(90),
|
||||
MinRetentionPeriod = TimeSpan.FromDays(1),
|
||||
MaxRetentionPeriod = TimeSpan.FromDays(365)
|
||||
};
|
||||
_service = new InMemoryRetentionPolicyService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryRetentionPolicyService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_CreatesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Delivery Log Cleanup",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Action = RetentionAction.Delete
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Delivery Log Cleanup", retrieved.Name);
|
||||
Assert.Equal(RetentionDataType.DeliveryLogs, retrieved.DataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_DuplicateId_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy 1",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_RetentionTooShort_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Too Short",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromHours(1) // Less than 1 day minimum
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_RetentionTooLong_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Too Long",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(500) // More than 365 days maximum
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterPolicyAsync_ArchiveWithoutLocation_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Archive Without Location",
|
||||
DataType = RetentionDataType.AuditLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(90),
|
||||
Action = RetentionAction.Archive
|
||||
// Missing ArchiveLocation
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.RegisterPolicyAsync(policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePolicyAsync_UpdatesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Original Name",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act
|
||||
var updated = policy with { Name = "Updated Name" };
|
||||
await _service.UpdatePolicyAsync("policy-001", updated);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Updated Name", retrieved.Name);
|
||||
Assert.NotNull(retrieved.ModifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePolicyAsync_NotFound_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "nonexistent",
|
||||
Name = "Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
_service.UpdatePolicyAsync("nonexistent", policy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicyAsync_RemovesPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "To Delete",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
};
|
||||
await _service.RegisterPolicyAsync(policy);
|
||||
|
||||
// Act
|
||||
await _service.DeletePolicyAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
var retrieved = await _service.GetPolicyAsync("policy-001");
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPoliciesAsync_ReturnsAllPolicies()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy A",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Policy B",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(60)
|
||||
});
|
||||
|
||||
// Act
|
||||
var policies = await _service.ListPoliciesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, policies.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPoliciesAsync_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Global Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = null // Global
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Tenant Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = "tenant1"
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-003",
|
||||
Name = "Other Tenant Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
TenantId = "tenant2"
|
||||
});
|
||||
|
||||
// Act
|
||||
var tenant1Policies = await _service.ListPoliciesAsync("tenant1");
|
||||
|
||||
// Assert - should include global and tenant-specific
|
||||
Assert.Equal(2, tenant1Policies.Count);
|
||||
Assert.Contains(tenant1Policies, p => p.Id == "policy-001");
|
||||
Assert.Contains(tenant1Policies, p => p.Id == "policy-002");
|
||||
Assert.DoesNotContain(tenant1Policies, p => p.Id == "policy-003");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_WhenDisabled_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new RetentionPolicyOptions { Enabled = false };
|
||||
var service = new InMemoryRetentionPolicyService(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryRetentionPolicyService>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await service.ExecuteRetentionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Single(result.Errors);
|
||||
Assert.Contains("disabled", result.Errors[0].Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_ExecutesEnabledPolicies()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Enabled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Enabled = true
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Disabled Policy",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Enabled = false
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRetentionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Single(result.PoliciesExecuted);
|
||||
Assert.Contains("policy-001", result.PoliciesExecuted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRetentionAsync_SpecificPolicy_ExecutesOnlyThat()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy 1",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-002",
|
||||
Name = "Policy 2",
|
||||
DataType = RetentionDataType.Escalations,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.ExecuteRetentionAsync("policy-002");
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.PoliciesExecuted);
|
||||
Assert.Equal("policy-002", result.PoliciesExecuted[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewRetentionAsync_ReturnsPreview()
|
||||
{
|
||||
// Arrange
|
||||
_service.RegisterHandler("DeliveryLogs", new TestRetentionHandler("DeliveryLogs", 100));
|
||||
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Delivery Cleanup",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
var preview = await _service.PreviewRetentionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("policy-001", preview.PolicyId);
|
||||
Assert.Equal(100, preview.TotalAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewRetentionAsync_NotFound_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
_service.PreviewRetentionAsync("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionHistoryAsync_ReturnsHistory()
|
||||
{
|
||||
// Arrange
|
||||
_service.RegisterHandler("DeliveryLogs", new TestRetentionHandler("DeliveryLogs", 50));
|
||||
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
// Execute twice
|
||||
await _service.ExecuteRetentionAsync("policy-001");
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
await _service.ExecuteRetentionAsync("policy-001");
|
||||
|
||||
// Act
|
||||
var history = await _service.GetExecutionHistoryAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, history.Count);
|
||||
Assert.All(history, r => Assert.True(r.Success));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextExecutionAsync_ReturnsNextTime()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Scheduled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30),
|
||||
Schedule = "0 0 * * *" // Daily at midnight
|
||||
});
|
||||
|
||||
// Act
|
||||
var next = await _service.GetNextExecutionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextExecutionAsync_NoSchedule_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
await _service.RegisterPolicyAsync(new RetentionPolicy
|
||||
{
|
||||
Id = "policy-001",
|
||||
Name = "Unscheduled Policy",
|
||||
DataType = RetentionDataType.DeliveryLogs,
|
||||
RetentionPeriod = TimeSpan.FromDays(30)
|
||||
// No schedule
|
||||
});
|
||||
|
||||
// Act
|
||||
var next = await _service.GetNextExecutionAsync("policy-001");
|
||||
|
||||
// Assert
|
||||
Assert.Null(next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDeliveryLogPolicy_CreatesValidPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = RetentionPolicyExtensions.CreateDeliveryLogPolicy(
|
||||
"delivery-logs-cleanup",
|
||||
TimeSpan.FromDays(30),
|
||||
"tenant1",
|
||||
"admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("delivery-logs-cleanup", policy.Id);
|
||||
Assert.Equal(RetentionDataType.DeliveryLogs, policy.DataType);
|
||||
Assert.Equal(TimeSpan.FromDays(30), policy.RetentionPeriod);
|
||||
Assert.Equal("tenant1", policy.TenantId);
|
||||
Assert.Equal("admin", policy.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAuditArchivePolicy_CreatesValidPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = RetentionPolicyExtensions.CreateAuditArchivePolicy(
|
||||
"audit-archive",
|
||||
TimeSpan.FromDays(365),
|
||||
"s3://bucket/archive",
|
||||
"tenant1",
|
||||
"admin");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("audit-archive", policy.Id);
|
||||
Assert.Equal(RetentionDataType.AuditLogs, policy.DataType);
|
||||
Assert.Equal(RetentionAction.Archive, policy.Action);
|
||||
Assert.Equal("s3://bucket/archive", policy.ArchiveLocation);
|
||||
}
|
||||
|
||||
private sealed class TestRetentionHandler : IRetentionHandler
|
||||
{
|
||||
public string DataType { get; }
|
||||
private readonly long _count;
|
||||
|
||||
public TestRetentionHandler(string dataType, long count)
|
||||
{
|
||||
DataType = dataType;
|
||||
_count = count;
|
||||
}
|
||||
|
||||
public Task<long> CountAsync(RetentionQuery query, CancellationToken ct) => Task.FromResult(_count);
|
||||
public Task<long> DeleteAsync(RetentionQuery query, CancellationToken ct) => Task.FromResult(_count);
|
||||
public Task<long> ArchiveAsync(RetentionQuery query, string archiveLocation, CancellationToken ct) => Task.FromResult(_count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class HtmlSanitizerTests
|
||||
{
|
||||
private readonly HtmlSanitizerOptions _options;
|
||||
private readonly DefaultHtmlSanitizer _sanitizer;
|
||||
|
||||
public HtmlSanitizerTests()
|
||||
{
|
||||
_options = new HtmlSanitizerOptions
|
||||
{
|
||||
DefaultProfile = "basic",
|
||||
LogSanitization = false
|
||||
};
|
||||
_sanitizer = new DefaultHtmlSanitizer(
|
||||
Options.Create(_options),
|
||||
NullLogger<DefaultHtmlSanitizer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_AllowedTags_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>", result);
|
||||
Assert.Contains("<strong>", result);
|
||||
Assert.Contains("</strong>", result);
|
||||
Assert.Contains("</p>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DisallowedTags_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><iframe src='evil.com'></iframe>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
Assert.DoesNotContain("<iframe", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_ScriptTags_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><script>alert('xss')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
Assert.DoesNotContain("<script", result);
|
||||
Assert.DoesNotContain("alert", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_EventHandlers_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p onclick='alert(1)'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("onclick", result);
|
||||
Assert.Contains("<p>Hello</p>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_JavaScriptUrls_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='javascript:alert(1)'>Click</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("javascript:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_AllowedAttributes_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='https://example.com' title='Example'>Link</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("href=", result);
|
||||
Assert.Contains("https://example.com", result);
|
||||
Assert.Contains("title=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DisallowedAttributes_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p data-custom='value' class='test'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("data-custom", result);
|
||||
Assert.Contains("class=", result); // class is allowed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_WithMinimalProfile_OnlyBasicTags()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p><a href='https://example.com'>Link</a></p>";
|
||||
var profile = SanitizationProfile.Minimal;
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<p>", result);
|
||||
Assert.DoesNotContain("<a", result); // links not in minimal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_WithRichProfile_AllowsImagesAndTables()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<table><tr><td>Cell</td></tr></table><img src='test.png' alt='Test'>";
|
||||
var profile = SanitizationProfile.Rich;
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<table>", result);
|
||||
Assert.Contains("<img", result);
|
||||
Assert.Contains("src=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_HtmlComments_Removed()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello<!-- comment --></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<!--", result);
|
||||
Assert.DoesNotContain("comment", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize("");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_NullString_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SafeHtml_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ScriptTag_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<script>alert('xss')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.ScriptDetected);
|
||||
Assert.True(result.ContainedDangerousContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EventHandler_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p onclick='alert(1)'>Hello</p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.EventHandlerDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_JavaScriptUrl_ReturnsErrors()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='javascript:void(0)'>Click</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Type == HtmlValidationErrorType.JavaScriptUrlDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisallowedTags_ReturnsWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello</p><custom-tag>Custom</custom-tag>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Validate(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result.RemovedTags, t => t == "custom-tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeHtml_EscapesSpecialCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var text = "<script>alert('test')</script>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.EscapeHtml(text);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<", result);
|
||||
Assert.DoesNotContain(">", result);
|
||||
Assert.Contains("<", result);
|
||||
Assert.Contains(">", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripTags_RemovesAllTags()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<p>Hello <strong>World</strong></p>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.StripTags(html);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<", result);
|
||||
Assert.DoesNotContain(">", result);
|
||||
Assert.Contains("Hello", result);
|
||||
Assert.Contains("World", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfile_ExistingProfile_ReturnsProfile()
|
||||
{
|
||||
// Act
|
||||
var profile = _sanitizer.GetProfile("basic");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("basic", profile.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfile_NonExistentProfile_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var profile = _sanitizer.GetProfile("non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterProfile_AddsCustomProfile()
|
||||
{
|
||||
// Arrange
|
||||
var customProfile = new SanitizationProfile
|
||||
{
|
||||
Name = "custom",
|
||||
AllowedTags = new HashSet<string> { "p", "custom-tag" }
|
||||
};
|
||||
|
||||
// Act
|
||||
_sanitizer.RegisterProfile("custom", customProfile);
|
||||
var retrieved = _sanitizer.GetProfile("custom");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("custom", retrieved.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<p>Test</p>", "<p>Test</p>")]
|
||||
[InlineData("<P>Test</P>", "<p>Test</p>")]
|
||||
[InlineData("<DIV>Test</DIV>", "<div>Test</div>")]
|
||||
public void Sanitize_NormalizesTagCase(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_SafeUrlSchemes_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<a href='mailto:test@example.com'>Email</a>";
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("mailto:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_DataUrl_RemovedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var html = "<img src='data:image/png;base64,abc123' />";
|
||||
var profile = SanitizationProfile.Rich with { AllowDataUrls = false };
|
||||
|
||||
// Act
|
||||
var result = _sanitizer.Sanitize(html, profile);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("data:", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class SigningServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly LocalSigningKeyProvider _keyProvider;
|
||||
private readonly SigningService _signingService;
|
||||
|
||||
public SigningServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new SigningServiceOptions
|
||||
{
|
||||
LocalSigningKey = "test-signing-key-for-unit-tests",
|
||||
Algorithm = "HMACSHA256",
|
||||
DefaultExpiry = TimeSpan.FromHours(24)
|
||||
};
|
||||
_keyProvider = new LocalSigningKeyProvider(
|
||||
Options.Create(_options),
|
||||
_timeProvider);
|
||||
_signingService = new SigningService(
|
||||
_keyProvider,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<SigningService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_CreatesValidToken()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
|
||||
// Act
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(token);
|
||||
Assert.Contains(".", token);
|
||||
var parts = token.Split('.');
|
||||
Assert.Equal(3, parts.Length); // header.body.signature
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidToken_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.Payload);
|
||||
Assert.Equal(payload.TokenId, result.Payload.TokenId);
|
||||
Assert.Equal(payload.Purpose, result.Payload.Purpose);
|
||||
Assert.Equal(payload.TenantId, result.Payload.TenantId);
|
||||
Assert.Equal(payload.Subject, result.Payload.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ExpiredToken_ReturnsExpired()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.Expired, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedToken_ReturnsInvalidSignature()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Tamper with the token
|
||||
var parts = token.Split('.');
|
||||
var tamperedToken = $"{parts[0]}.{parts[1]}.tampered_signature";
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(tamperedToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.InvalidSignature, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MalformedToken_ReturnsInvalidFormat()
|
||||
{
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync("not-a-valid-token");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(SigningErrorCode.InvalidFormat, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenInfo_ValidToken_ReturnsInfo()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Act
|
||||
var info = _signingService.GetTokenInfo(token);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal(payload.TokenId, info.TokenId);
|
||||
Assert.Equal(payload.Purpose, info.Purpose);
|
||||
Assert.Equal(payload.TenantId, info.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTokenInfo_MalformedToken_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var info = _signingService.GetTokenInfo("not-a-valid-token");
|
||||
|
||||
// Assert
|
||||
Assert.Null(info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateKeyAsync_CreatesNewKey()
|
||||
{
|
||||
// Arrange
|
||||
var keysBefore = await _keyProvider.ListKeyIdsAsync();
|
||||
|
||||
// Act
|
||||
var success = await _signingService.RotateKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
var keysAfter = await _keyProvider.ListKeyIdsAsync();
|
||||
Assert.True(keysAfter.Count > keysBefore.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithClaims_PreservesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24),
|
||||
Claims = new Dictionary<string, string>
|
||||
{
|
||||
["custom1"] = "value1",
|
||||
["custom2"] = "value2"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.Payload);
|
||||
Assert.Equal(2, result.Payload.Claims.Count);
|
||||
Assert.Equal("value1", result.Payload.Claims["custom1"]);
|
||||
Assert.Equal("value2", result.Payload.Claims["custom2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AfterKeyRotation_StillVerifiesOldTokens()
|
||||
{
|
||||
// Arrange - sign with old key
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = "token-001",
|
||||
Purpose = "incident.ack",
|
||||
TenantId = "tenant1",
|
||||
Subject = "incident-123",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
|
||||
};
|
||||
var token = await _signingService.SignAsync(payload);
|
||||
|
||||
// Rotate key
|
||||
await _signingService.RotateKeyAsync();
|
||||
|
||||
// Act - verify with new current key (but old key should still be available)
|
||||
var result = await _signingService.VerifyAsync(token);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
|
||||
public class LocalSigningKeyProviderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SigningServiceOptions _options;
|
||||
private readonly LocalSigningKeyProvider _keyProvider;
|
||||
|
||||
public LocalSigningKeyProviderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new SigningServiceOptions
|
||||
{
|
||||
LocalSigningKey = "test-key",
|
||||
KeyRetentionPeriod = TimeSpan.FromDays(90)
|
||||
};
|
||||
_keyProvider = new LocalSigningKeyProvider(
|
||||
Options.Create(_options),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentKeyAsync_ReturnsKey()
|
||||
{
|
||||
// Act
|
||||
var key = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.True(key.IsCurrent);
|
||||
Assert.NotEmpty(key.KeyMaterial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKeyByIdAsync_ExistingKey_ReturnsKey()
|
||||
{
|
||||
// Arrange
|
||||
var currentKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
var key = await _keyProvider.GetKeyByIdAsync(currentKey.KeyId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal(currentKey.KeyId, key.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKeyByIdAsync_NonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var key = await _keyProvider.GetKeyByIdAsync("non-existent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_CreatesNewCurrentKey()
|
||||
{
|
||||
// Arrange
|
||||
var oldKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
var newKey = await _keyProvider.RotateAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(oldKey.KeyId, newKey.KeyId);
|
||||
Assert.True(newKey.IsCurrent);
|
||||
|
||||
var currentKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
Assert.Equal(newKey.KeyId, currentKey.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateAsync_KeepsOldKeyForVerification()
|
||||
{
|
||||
// Arrange
|
||||
var oldKey = await _keyProvider.GetCurrentKeyAsync();
|
||||
|
||||
// Act
|
||||
await _keyProvider.RotateAsync();
|
||||
|
||||
// Assert - old key should still be retrievable
|
||||
var retrievedOldKey = await _keyProvider.GetKeyByIdAsync(oldKey.KeyId);
|
||||
Assert.NotNull(retrievedOldKey);
|
||||
Assert.False(retrievedOldKey.IsCurrent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListKeyIdsAsync_ReturnsAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
await _keyProvider.RotateAsync();
|
||||
await _keyProvider.RotateAsync();
|
||||
|
||||
// Act
|
||||
var keyIds = await _keyProvider.ListKeyIdsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, keyIds.Count); // Initial + 2 rotations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class TenantIsolationValidatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly TenantIsolationOptions _options;
|
||||
private readonly InMemoryTenantIsolationValidator _validator;
|
||||
|
||||
public TenantIsolationValidatorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new TenantIsolationOptions
|
||||
{
|
||||
EnforceStrict = true,
|
||||
LogViolations = false,
|
||||
RecordViolations = true,
|
||||
AllowCrossTenantGrants = true,
|
||||
SystemResourceTypes = ["system-template"],
|
||||
AdminTenantPatterns = ["^admin$", "^system$"]
|
||||
};
|
||||
_validator = new InMemoryTenantIsolationValidator(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryTenantIsolationValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant1", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SameTenant, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_DifferentTenant_Denied()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.Denied, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_AdminTenant_AlwaysAllowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"admin", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SystemResource, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_SystemResource_AlwaysAllowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "system-template", "template-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "system-template", "template-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SystemResource, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateResourceAccessAsync_UnregisteredResource_Allowed()
|
||||
{
|
||||
// Act - resource not registered
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant1", "delivery", "unregistered-delivery", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.ResourceNotFound, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDeliveryAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateDeliveryAsync("tenant1", "delivery-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateChannelAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "channel", "channel-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateChannelAsync("tenant1", "channel-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateTemplateAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "template", "template-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateTemplateAsync("tenant1", "template-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSubscriptionAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "subscription", "subscription-001");
|
||||
|
||||
// Act
|
||||
var result = await _validator.ValidateSubscriptionAsync("tenant1", "subscription-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_EnablesAccess()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.True(result.IsCrossTenant);
|
||||
Assert.NotNull(result.GrantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeCrossTenantAccessAsync_DisablesAccess()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
// Act
|
||||
await _validator.RevokeCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001", "admin");
|
||||
|
||||
var result = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_WithExpiry_ExpiresCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
var expiresAt = _timeProvider.GetUtcNow().AddHours(1);
|
||||
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, expiresAt, "admin");
|
||||
|
||||
// Verify access before expiry
|
||||
var resultBefore = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
Assert.True(resultBefore.IsAllowed);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Act
|
||||
var resultAfter = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultAfter.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantCrossTenantAccessAsync_OperationRestrictions_Enforced()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.GrantCrossTenantAccessAsync(
|
||||
"tenant1", "tenant2", "delivery", "delivery-001",
|
||||
TenantAccessOperation.Read, null, "admin");
|
||||
|
||||
// Act - Read should be allowed
|
||||
var readResult = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Write should be denied (not in granted operations)
|
||||
var writeResult = await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Write);
|
||||
|
||||
// Assert
|
||||
Assert.True(readResult.IsAllowed);
|
||||
Assert.False(writeResult.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetViolationsAsync_ReturnsRecordedViolations()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Trigger a violation
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
// Act
|
||||
var violations = await _validator.GetViolationsAsync("tenant2");
|
||||
|
||||
// Assert
|
||||
Assert.Single(violations);
|
||||
Assert.Equal("tenant2", violations[0].RequestingTenantId);
|
||||
Assert.Equal("tenant1", violations[0].ResourceOwnerTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetViolationsAsync_FiltersBySince()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-002");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-002", TenantAccessOperation.Read);
|
||||
|
||||
// Act
|
||||
var since = _timeProvider.GetUtcNow().AddHours(-1);
|
||||
var violations = await _validator.GetViolationsAsync(null, since);
|
||||
|
||||
// Assert
|
||||
Assert.Single(violations);
|
||||
Assert.Equal("delivery-002", violations[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterResourceAsync_AddsResource()
|
||||
{
|
||||
// Act
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
var resources = await _validator.GetTenantResourcesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(resources);
|
||||
Assert.Equal("tenant1", resources[0].TenantId);
|
||||
Assert.Equal("delivery", resources[0].ResourceType);
|
||||
Assert.Equal("delivery-001", resources[0].ResourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterResourceAsync_RemovesResource()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Act
|
||||
await _validator.UnregisterResourceAsync("delivery", "delivery-001");
|
||||
var resources = await _validator.GetTenantResourcesAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(resources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTenantResourcesAsync_FiltersByResourceType()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
await _validator.RegisterResourceAsync("tenant1", "channel", "channel-001");
|
||||
|
||||
// Act
|
||||
var deliveries = await _validator.GetTenantResourcesAsync("tenant1", "delivery");
|
||||
|
||||
// Assert
|
||||
Assert.Single(deliveries);
|
||||
Assert.Equal("delivery", deliveries[0].ResourceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunFuzzTestAsync_AllTestsPass()
|
||||
{
|
||||
// Arrange
|
||||
var config = new TenantFuzzTestConfig
|
||||
{
|
||||
Iterations = 20,
|
||||
TenantIds = ["tenant-a", "tenant-b"],
|
||||
ResourceTypes = ["delivery", "channel"],
|
||||
TestCrossTenantGrants = true,
|
||||
TestEdgeCases = true,
|
||||
Seed = 42 // For reproducibility
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _validator.RunFuzzTestAsync(config);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.AllPassed);
|
||||
Assert.True(result.TotalTests > 0);
|
||||
Assert.Equal(result.TotalTests, result.PassedTests);
|
||||
Assert.Empty(result.Failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateCrossTenantAccessAsync_SameTenant_Allowed()
|
||||
{
|
||||
// Act
|
||||
var result = await _validator.ValidateCrossTenantAccessAsync(
|
||||
"tenant1", "tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(TenantValidationType.SameTenant, result.ValidationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViolationSeverity_ReflectsOperation()
|
||||
{
|
||||
// Arrange
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-001");
|
||||
|
||||
// Trigger different violations
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-001", TenantAccessOperation.Read);
|
||||
|
||||
await _validator.RegisterResourceAsync("tenant1", "delivery", "delivery-002");
|
||||
await _validator.ValidateResourceAccessAsync(
|
||||
"tenant2", "delivery", "delivery-002", TenantAccessOperation.Delete);
|
||||
|
||||
// Act
|
||||
var violations = await _validator.GetViolationsAsync("tenant2");
|
||||
|
||||
// Assert
|
||||
var readViolation = violations.FirstOrDefault(v => v.ResourceId == "delivery-001");
|
||||
var deleteViolation = violations.FirstOrDefault(v => v.ResourceId == "delivery-002");
|
||||
|
||||
Assert.NotNull(readViolation);
|
||||
Assert.NotNull(deleteViolation);
|
||||
Assert.Equal(ViolationSeverity.Low, readViolation.Severity);
|
||||
Assert.Equal(ViolationSeverity.Critical, deleteViolation.Severity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Security;
|
||||
|
||||
public class WebhookSecurityServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly WebhookSecurityOptions _options;
|
||||
private readonly InMemoryWebhookSecurityService _webhookService;
|
||||
|
||||
public WebhookSecurityServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new WebhookSecurityOptions
|
||||
{
|
||||
DefaultAlgorithm = "SHA256",
|
||||
EnableReplayProtection = true,
|
||||
NonceCacheExpiry = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
_webhookService = new InMemoryWebhookSecurityService(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryWebhookSecurityService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_NoConfig_ReturnsValidWithWarning()
|
||||
{
|
||||
// Arrange
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("No webhook security configuration"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidSignature_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
Algorithm = "SHA256",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var body = "{\"test\": \"data\"}";
|
||||
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = body,
|
||||
Signature = signature
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidSignature_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
Signature = "invalid-signature"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_MissingSignature_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Missing signature"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_IpNotInAllowlist_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
SourceIp = "10.0.0.1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_IpInAllowlist_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
SourceIp = "192.168.1.100"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ExpiredTimestamp_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = false,
|
||||
MaxRequestAge = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = "{\"test\": \"data\"}",
|
||||
Timestamp = _timeProvider.GetUtcNow().AddMinutes(-10)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.NotExpired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReplayAttack_ReturnsDenied()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
RequireSignature = true
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
var body = "{\"test\": \"data\"}";
|
||||
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
||||
|
||||
var request = new WebhookValidationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
Body = body,
|
||||
Signature = signature,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// First request should succeed
|
||||
var result1 = await _webhookService.ValidateAsync(request);
|
||||
Assert.True(result1.IsValid);
|
||||
|
||||
// Act - second request with same signature should fail
|
||||
var result2 = await _webhookService.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result2.IsValid);
|
||||
Assert.True(result2.FailedChecks.HasFlag(WebhookValidationChecks.NotReplay));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSignature_ProducesConsistentOutput()
|
||||
{
|
||||
// Arrange
|
||||
var payload = "{\"test\": \"data\"}";
|
||||
var secretKey = "test-secret";
|
||||
|
||||
// Act
|
||||
var sig1 = _webhookService.GenerateSignature(payload, secretKey);
|
||||
var sig2 = _webhookService.GenerateSignature(payload, secretKey);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAllowlistAsync_UpdatesConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
await _webhookService.UpdateAllowlistAsync(
|
||||
"tenant1", "channel1", ["10.0.0.0/8"], "admin");
|
||||
|
||||
// Assert
|
||||
var updatedConfig = await _webhookService.GetConfigAsync("tenant1", "channel1");
|
||||
Assert.NotNull(updatedConfig);
|
||||
Assert.Single(updatedConfig.AllowedIps);
|
||||
Assert.Equal("10.0.0.0/8", updatedConfig.AllowedIps[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_NoConfig_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.1");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_CidrMatch_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.0/24"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.50");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsIpAllowedAsync_ExactMatch_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var config = new WebhookSecurityConfig
|
||||
{
|
||||
ConfigId = "config-001",
|
||||
TenantId = "tenant1",
|
||||
ChannelId = "channel1",
|
||||
SecretKey = "test-secret-key",
|
||||
EnforceIpAllowlist = true,
|
||||
AllowedIps = ["192.168.1.100"]
|
||||
};
|
||||
await _webhookService.RegisterWebhookAsync(config);
|
||||
|
||||
// Act
|
||||
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.100");
|
||||
|
||||
// Assert
|
||||
Assert.True(allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Simulation;
|
||||
|
||||
public class SimulationEngineTests
|
||||
{
|
||||
private readonly Mock<INotifyRuleRepository> _ruleRepository;
|
||||
private readonly Mock<INotifyRuleEvaluator> _ruleEvaluator;
|
||||
private readonly Mock<INotifyChannelRepository> _channelRepository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly SimulationEngine _engine;
|
||||
|
||||
public SimulationEngineTests()
|
||||
{
|
||||
_ruleRepository = new Mock<INotifyRuleRepository>();
|
||||
_ruleEvaluator = new Mock<INotifyRuleEvaluator>();
|
||||
_channelRepository = new Mock<INotifyChannelRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_options = new SimulationOptions();
|
||||
|
||||
_engine = new SimulationEngine(
|
||||
_ruleRepository.Object,
|
||||
_ruleEvaluator.Object,
|
||||
_channelRepository.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<SimulationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithMatchingRules_ReturnsMatchedResults()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
var channel = CreateTestChannel("channel-1");
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_channelRepository
|
||||
.Setup(c => c.GetAsync(It.IsAny<string>(), "channel-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(channel);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? ts) =>
|
||||
NotifyRuleEvaluationOutcome.Matched(r, r.Actions, ts ?? _timeProvider.GetUtcNow()));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("sim-", result.SimulationId);
|
||||
Assert.Equal(1, result.TotalEvents);
|
||||
Assert.Equal(1, result.TotalRules);
|
||||
Assert.Equal(1, result.MatchedEvents);
|
||||
Assert.True(result.TotalActionsTriggered > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoMatchingRules_ReturnsNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.MatchedEvents);
|
||||
Assert.Equal(0, result.TotalActionsTriggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithIncludeNonMatches_ReturnsNonMatchReasons()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "severity_below_threshold"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events,
|
||||
IncludeNonMatches = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.EventResults);
|
||||
Assert.NotNull(result.EventResults[0].NonMatchedRules);
|
||||
Assert.Single(result.EventResults[0].NonMatchedRules);
|
||||
Assert.Equal("severity_below_threshold", result.EventResults[0].NonMatchedRules[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithProvidedRules_UsesProvidedRules()
|
||||
{
|
||||
// Arrange
|
||||
var providedRules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("custom-rule-1"),
|
||||
CreateTestRule("custom-rule-2")
|
||||
};
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events,
|
||||
Rules = providedRules
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.TotalRules);
|
||||
_ruleRepository.Verify(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoEvents_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule> { CreateTestRule("rule-1") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = new List<NotifyEvent>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.TotalEvents);
|
||||
Assert.Empty(result.EventResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WithNoRules_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<NotifyEvent> { CreateTestEvent("event.test") };
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<NotifyRule>());
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.TotalRules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_BuildsRuleSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("rule-1"),
|
||||
CreateTestRule("rule-2")
|
||||
};
|
||||
var events = new List<NotifyEvent>
|
||||
{
|
||||
CreateTestEvent("event.test"),
|
||||
CreateTestEvent("event.test"),
|
||||
CreateTestEvent("event.test")
|
||||
};
|
||||
var channel = CreateTestChannel("channel-1");
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_channelRepository
|
||||
.Setup(c => c.GetAsync(It.IsAny<string>(), "channel-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(channel);
|
||||
|
||||
var callCount = 0;
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? ts) =>
|
||||
{
|
||||
callCount++;
|
||||
// First rule matches all, second rule matches none
|
||||
if (r.RuleId == "rule-1")
|
||||
return NotifyRuleEvaluationOutcome.Matched(r, r.Actions, ts ?? _timeProvider.GetUtcNow());
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(r, "event_kind_mismatch");
|
||||
});
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.RuleSummaries.Count);
|
||||
|
||||
var rule1Summary = result.RuleSummaries.First(s => s.RuleId == "rule-1");
|
||||
Assert.Equal(3, rule1Summary.MatchCount);
|
||||
Assert.Equal(100.0, rule1Summary.MatchPercentage);
|
||||
|
||||
var rule2Summary = result.RuleSummaries.First(s => s.RuleId == "rule-2");
|
||||
Assert.Equal(0, rule2Summary.MatchCount);
|
||||
Assert.Equal(0.0, rule2Summary.MatchPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_FiltersDisabledRulesWhenEnabledRulesOnly()
|
||||
{
|
||||
// Arrange
|
||||
var rules = new List<NotifyRule>
|
||||
{
|
||||
CreateTestRule("rule-1", enabled: true),
|
||||
CreateTestRule("rule-2", enabled: false)
|
||||
};
|
||||
|
||||
_ruleRepository
|
||||
.Setup(r => r.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(rules);
|
||||
|
||||
_ruleEvaluator
|
||||
.Setup(e => e.Evaluate(It.IsAny<NotifyRule>(), It.IsAny<NotifyEvent>(), It.IsAny<DateTimeOffset?>()))
|
||||
.Returns((NotifyRule r, NotifyEvent _, DateTimeOffset? _) =>
|
||||
NotifyRuleEvaluationOutcome.NotMatched(r, "test"));
|
||||
|
||||
var request = new SimulationRequest
|
||||
{
|
||||
TenantId = "tenant1",
|
||||
Events = new List<NotifyEvent> { CreateTestEvent("event.test") },
|
||||
EnabledRulesOnly = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _engine.SimulateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.TotalRules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_ValidRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = CreateTestRule("valid-rule");
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_BroadMatchRule_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "broad-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "Broad Rule",
|
||||
match: NotifyRuleMatch.Create(),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1") },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "broad_match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_DisabledRule_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = CreateTestRule("disabled-rule", enabled: false);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "rule_disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_UnknownSeverity_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "bad-severity-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "Bad Severity Rule",
|
||||
match: NotifyRuleMatch.Create(minSeverity: "mega-critical"),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1") },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "unknown_severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRuleAsync_NoEnabledActions_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "no-actions-rule",
|
||||
tenantId: "tenant1",
|
||||
name: "No Actions Rule",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "test" }),
|
||||
actions: new[] { NotifyRuleAction.Create("action-1", "channel-1", enabled: false) },
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
var result = await _engine.ValidateRuleAsync(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Code == "no_enabled_actions");
|
||||
}
|
||||
|
||||
private NotifyRule CreateTestRule(string ruleId, bool enabled = true)
|
||||
{
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: "tenant1",
|
||||
name: $"Test Rule {ruleId}",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "event.test" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "action-1",
|
||||
channel: "channel-1",
|
||||
template: "default",
|
||||
enabled: true)
|
||||
},
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private NotifyEvent CreateTestEvent(string kind)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: kind,
|
||||
tenant: "tenant1",
|
||||
ts: _timeProvider.GetUtcNow(),
|
||||
payload: null);
|
||||
}
|
||||
|
||||
private NotifyChannel CreateTestChannel(string channelId)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: "tenant1",
|
||||
name: $"Test Channel {channelId}",
|
||||
type: NotifyChannelType.Custom,
|
||||
enabled: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.StormBreaker;
|
||||
|
||||
public class InMemoryStormBreakerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly StormBreakerOptions _options;
|
||||
private readonly InMemoryStormBreaker _stormBreaker;
|
||||
|
||||
public InMemoryStormBreakerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_options = new StormBreakerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultThreshold = 10,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
SummaryInterval = TimeSpan.FromMinutes(15),
|
||||
StormCooldown = TimeSpan.FromMinutes(10),
|
||||
MaxEventsTracked = 100,
|
||||
MaxSampleEvents = 5
|
||||
};
|
||||
_stormBreaker = new InMemoryStormBreaker(
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BelowThreshold_ReturnsNoStorm()
|
||||
{
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event1");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendNormally, result.Action);
|
||||
Assert.Equal(1, result.EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AtThreshold_DetectsStorm()
|
||||
{
|
||||
// Arrange - add events up to threshold
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act - 10th event triggers storm
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event9");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendStormAlert, result.Action);
|
||||
Assert.Equal(10, result.EventCount);
|
||||
Assert.NotNull(result.StormStartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AfterStormDetected_SuppressesEvents()
|
||||
{
|
||||
// Arrange - trigger storm
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act - next event after storm detected
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event10");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.Suppress, result.Action);
|
||||
Assert.Equal(1, result.SuppressedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AtSummaryInterval_TriggersSummary()
|
||||
{
|
||||
// Arrange - trigger storm
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Advance time past summary interval
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(16));
|
||||
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "event_after_interval");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendSummary, result.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DisabledStormBreaker_ReturnsNoStorm()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new StormBreakerOptions { Enabled = false };
|
||||
var disabledBreaker = new InMemoryStormBreaker(
|
||||
Options.Create(disabledOptions),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var result = await disabledBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
|
||||
// All events should return no storm when disabled
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(StormAction.SendNormally, result.Action);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentKeys_TrackedSeparately()
|
||||
{
|
||||
// Arrange - trigger storm for key1
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _stormBreaker.EvaluateAsync("tenant1", "key1", "eventA");
|
||||
var result2 = await _stormBreaker.EvaluateAsync("tenant1", "key2", "eventB");
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsStorm);
|
||||
Assert.False(result2.IsStorm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentTenants_TrackedSeparately()
|
||||
{
|
||||
// Arrange - trigger storm for tenant1
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var result1 = await _stormBreaker.EvaluateAsync("tenant1", "key1", "eventA");
|
||||
var result2 = await _stormBreaker.EvaluateAsync("tenant2", "key1", "eventB");
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.IsStorm);
|
||||
Assert.False(result2.IsStorm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ExistingStorm_ReturnsState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var state = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state);
|
||||
Assert.Equal("tenant1", state.TenantId);
|
||||
Assert.Equal("key1", state.StormKey);
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal(10, state.EventIds.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_NoStorm_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var state = await _stormBreaker.GetStateAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveStormsAsync_ReturnsActiveStormsOnly()
|
||||
{
|
||||
// Arrange - create two storms
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event1_{i}");
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key2", $"event2_{i}");
|
||||
}
|
||||
|
||||
// Create a non-storm (below threshold)
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key3", "event3_0");
|
||||
|
||||
// Act
|
||||
var activeStorms = await _stormBreaker.GetActiveStormsAsync("tenant1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, activeStorms.Count);
|
||||
Assert.Contains(activeStorms, s => s.StormKey == "key1");
|
||||
Assert.Contains(activeStorms, s => s.StormKey == "key2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_RemovesStormState()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
var beforeClear = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
Assert.NotNull(beforeClear);
|
||||
|
||||
// Act
|
||||
await _stormBreaker.ClearAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
var afterClear = await _stormBreaker.GetStateAsync("tenant1", "key1");
|
||||
Assert.Null(afterClear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSummaryAsync_ActiveStorm_ReturnsSummary()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Advance some time
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Act
|
||||
var summary = await _stormBreaker.GenerateSummaryAsync("tenant1", "key1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(summary);
|
||||
Assert.Equal("tenant1", summary.TenantId);
|
||||
Assert.Equal("key1", summary.StormKey);
|
||||
Assert.Equal(15, summary.TotalEvents);
|
||||
Assert.True(summary.IsOngoing);
|
||||
Assert.NotNull(summary.SummaryText);
|
||||
Assert.NotNull(summary.SummaryTitle);
|
||||
Assert.True(summary.SampleEventIds.Count <= _options.MaxSampleEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSummaryAsync_NoStorm_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var summary = await _stormBreaker.GenerateSummaryAsync("tenant1", "nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EventsOutsideWindow_AreRemoved()
|
||||
{
|
||||
// Arrange - add events
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _stormBreaker.EvaluateAsync("tenant1", "key1", $"event{i}");
|
||||
}
|
||||
|
||||
// Move time past the window
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
// Act
|
||||
var result = await _stormBreaker.EvaluateAsync("tenant1", "key1", "new_event");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsStorm);
|
||||
Assert.Equal(1, result.EventCount); // Only the new event counts
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThresholdOverrides_AppliesCorrectThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var optionsWithOverride = new StormBreakerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultThreshold = 100,
|
||||
DefaultWindow = TimeSpan.FromMinutes(5),
|
||||
ThresholdOverrides = new Dictionary<string, int>
|
||||
{
|
||||
["critical.*"] = 5
|
||||
}
|
||||
};
|
||||
|
||||
var breaker = new InMemoryStormBreaker(
|
||||
Options.Create(optionsWithOverride),
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryStormBreaker>.Instance);
|
||||
|
||||
// Act - 5 events should trigger storm for critical.* keys
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await breaker.EvaluateAsync("tenant1", "critical.alert", $"event{i}");
|
||||
}
|
||||
|
||||
var criticalResult = await breaker.EvaluateAsync("tenant1", "critical.alert", "event5");
|
||||
|
||||
// Non-critical key should not be in storm yet
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await breaker.EvaluateAsync("tenant1", "info.log", $"event{i}");
|
||||
}
|
||||
|
||||
var infoResult = await breaker.EvaluateAsync("tenant1", "info.log", "event5");
|
||||
|
||||
// Assert
|
||||
Assert.True(criticalResult.IsStorm);
|
||||
Assert.False(infoResult.IsStorm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Templates;
|
||||
|
||||
public sealed class EnhancedTemplateRendererTests
|
||||
{
|
||||
private readonly MockTemplateService _templateService;
|
||||
private readonly EnhancedTemplateRenderer _renderer;
|
||||
|
||||
public EnhancedTemplateRendererTests()
|
||||
{
|
||||
_templateService = new MockTemplateService();
|
||||
var options = Options.Create(new TemplateRendererOptions
|
||||
{
|
||||
ProvenanceBaseUrl = "https://stellaops.local/notify"
|
||||
});
|
||||
_renderer = new EnhancedTemplateRenderer(
|
||||
_templateService,
|
||||
options,
|
||||
NullLogger<EnhancedTemplateRenderer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_SimpleVariables_SubstitutesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Hello {{actor}}, event {{kind}} occurred.");
|
||||
var notifyEvent = CreateEvent("pack.approval", "test-user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Hello test-user", result.Body);
|
||||
Assert.Contains("pack.approval", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_NestedPayloadVariables_SubstitutesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Pack {{pack.id}} version {{pack.version}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["pack"] = new JsonObject
|
||||
{
|
||||
["id"] = "pkg-001",
|
||||
["version"] = "1.2.3"
|
||||
}
|
||||
};
|
||||
var notifyEvent = CreateEvent("pack.approval", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Pack pkg-001 version 1.2.3", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlock_IteratesArray()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Items: {{#each items}}[{{this}}]{{/each}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["items"] = new JsonArray { "a", "b", "c" }
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Items: [a][b][c]", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_EachBlockWithProperties_AccessesItemProperties()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("{{#each vulnerabilities}}{{@id}}: {{@severity}} {{/each}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["vulnerabilities"] = new JsonArray
|
||||
{
|
||||
new JsonObject { ["id"] = "CVE-001", ["severity"] = "high" },
|
||||
new JsonObject { ["id"] = "CVE-002", ["severity"] = "low" }
|
||||
}
|
||||
};
|
||||
var notifyEvent = CreateEvent("scan.complete", "scanner", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CVE-001: high CVE-002: low ", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_RedactsSensitiveFields()
|
||||
{
|
||||
// Arrange
|
||||
_templateService.RedactionConfig = new TemplateRedactionConfig
|
||||
{
|
||||
AllowedFields = [],
|
||||
DeniedFields = ["secret", "token"],
|
||||
Mode = "safe"
|
||||
};
|
||||
|
||||
var template = CreateTemplate("Secret: {{secretKey}}, Token: {{authToken}}, Name: {{name}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["secretKey"] = "super-secret-123",
|
||||
["authToken"] = "tok-456",
|
||||
["name"] = "public-name"
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[REDACTED]", result.Body);
|
||||
Assert.Contains("public-name", result.Body);
|
||||
Assert.DoesNotContain("super-secret-123", result.Body);
|
||||
Assert.DoesNotContain("tok-456", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_ParanoidMode_OnlyAllowsExplicitFields()
|
||||
{
|
||||
// Arrange
|
||||
_templateService.RedactionConfig = new TemplateRedactionConfig
|
||||
{
|
||||
AllowedFields = ["name"],
|
||||
DeniedFields = [],
|
||||
Mode = "paranoid"
|
||||
};
|
||||
|
||||
var template = CreateTemplate("Name: {{name}}, Email: {{email}}");
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["name"] = "John",
|
||||
["email"] = "john@example.com"
|
||||
};
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Name: John", result.Body);
|
||||
Assert.Contains("Email: [REDACTED]", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_AddsProvenanceLinks()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Event link: {{provenance.eventUrl}}");
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("https://stellaops.local/notify/events/", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_FormatSpecifiers_Work()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Upper: {{name|upper}}, Lower: {{name|lower}}");
|
||||
var payload = new JsonObject { ["name"] = "Test" };
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Upper: TEST", result.Body);
|
||||
Assert.Contains("Lower: test", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_HtmlFormat_EncodesSpecialChars()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Script: {{code|html}}", NotifyDeliveryFormat.Html);
|
||||
var payload = new JsonObject { ["code"] = "<script>alert('xss')</script>" };
|
||||
var notifyEvent = CreateEvent("test.event", "user", payload);
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<script>", result.Body);
|
||||
Assert.Contains("<script>", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_RendersSubject()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["subject"] = "[Alert] {{kind}} from {{actor}}"
|
||||
};
|
||||
var template = CreateTemplate("Body content", NotifyDeliveryFormat.PlainText, metadata);
|
||||
var notifyEvent = CreateEvent("security.alert", "scanner");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Subject);
|
||||
Assert.Contains("security.alert", result.Subject);
|
||||
Assert.Contains("scanner", result.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_ComputesBodyHash()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("Static content");
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result1 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
var result2 = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result1.BodyHash);
|
||||
Assert.Equal(64, result1.BodyHash.Length); // SHA-256 hex
|
||||
Assert.Equal(result1.BodyHash, result2.BodyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_MarkdownToHtml_Converts()
|
||||
{
|
||||
// Arrange
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-md",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Email,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "# Header\n**Bold** text",
|
||||
renderMode: NotifyTemplateRenderMode.Markdown,
|
||||
format: NotifyDeliveryFormat.Html);
|
||||
|
||||
var notifyEvent = CreateEvent("test.event", "user");
|
||||
|
||||
// Act
|
||||
var result = await _renderer.RenderAsync(template, notifyEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<h1>", result.Body);
|
||||
Assert.Contains("<strong>", result.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_IfBlock_RendersConditionally()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("{{#if critical}}CRITICAL: {{/if}}Message");
|
||||
var payloadTrue = new JsonObject { ["critical"] = "true" };
|
||||
var payloadFalse = new JsonObject { ["critical"] = "" };
|
||||
|
||||
var eventTrue = CreateEvent("test", "user", payloadTrue);
|
||||
var eventFalse = CreateEvent("test", "user", payloadFalse);
|
||||
|
||||
// Act
|
||||
var resultTrue = await _renderer.RenderAsync(template, eventTrue);
|
||||
var resultFalse = await _renderer.RenderAsync(template, eventFalse);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("CRITICAL:", resultTrue.Body);
|
||||
Assert.DoesNotContain("CRITICAL:", resultFalse.Body);
|
||||
}
|
||||
|
||||
private static NotifyTemplate CreateTemplate(
|
||||
string body,
|
||||
NotifyDeliveryFormat format = NotifyDeliveryFormat.PlainText,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
{
|
||||
return NotifyTemplate.Create(
|
||||
templateId: "test-template",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: body,
|
||||
format: format,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
private static NotifyEvent CreateEvent(
|
||||
string kind,
|
||||
string actor,
|
||||
JsonObject? payload = null)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid().ToString(),
|
||||
tenant: "test-tenant",
|
||||
kind: kind,
|
||||
actor: actor,
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
payload: payload ?? new JsonObject());
|
||||
}
|
||||
|
||||
private sealed class MockTemplateService : INotifyTemplateService
|
||||
{
|
||||
public TemplateRedactionConfig RedactionConfig { get; set; } = TemplateRedactionConfig.Default;
|
||||
|
||||
public Task<NotifyTemplate?> ResolveAsync(string tenantId, string key, NotifyChannelType channelType, string locale, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<NotifyTemplate?>(null);
|
||||
|
||||
public Task<NotifyTemplate?> GetByIdAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<NotifyTemplate?>(null);
|
||||
|
||||
public Task<TemplateUpsertResult> UpsertAsync(NotifyTemplate template, string actor, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TemplateUpsertResult.Created(template.TemplateId));
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string templateId, string actor, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, TemplateListOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NotifyTemplate>>([]);
|
||||
|
||||
public TemplateValidationResult Validate(string templateBody)
|
||||
=> TemplateValidationResult.Valid();
|
||||
|
||||
public TemplateRedactionConfig GetRedactionConfig(NotifyTemplate template)
|
||||
=> RedactionConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Templates;
|
||||
|
||||
public sealed class NotifyTemplateServiceTests
|
||||
{
|
||||
private readonly InMemoryTemplateRepository _templateRepository;
|
||||
private readonly InMemoryAuditRepository _auditRepository;
|
||||
private readonly NotifyTemplateService _service;
|
||||
|
||||
public NotifyTemplateServiceTests()
|
||||
{
|
||||
_templateRepository = new InMemoryTemplateRepository();
|
||||
_auditRepository = new InMemoryAuditRepository();
|
||||
_service = new NotifyTemplateService(
|
||||
_templateRepository,
|
||||
_auditRepository,
|
||||
NullLogger<NotifyTemplateService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExactLocaleMatch_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-001", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-001", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FallbackToLanguageOnly_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-en", "pack.approval", "en");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act - request en-GB but only en exists
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-GB");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-en", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FallbackToDefault_ReturnsTemplate()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-default", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act - request de-DE but only en-us exists (default)
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "de-DE");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tmpl-default", result.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(
|
||||
"test-tenant", "nonexistent.key", NotifyChannelType.Webhook, "en-US");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_NewTemplate_CreatesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-new", "pack.approval", "en-us");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(template, "test-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.IsNew);
|
||||
Assert.Equal("tmpl-new", result.TemplateId);
|
||||
|
||||
var audit = _auditRepository.Entries.Single();
|
||||
Assert.Equal("template.created", audit.EventType);
|
||||
Assert.Equal("test-actor", audit.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ExistingTemplate_UpdatesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Original body");
|
||||
await _templateRepository.UpsertAsync(original);
|
||||
_auditRepository.Entries.Clear();
|
||||
|
||||
var updated = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Updated body");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(updated, "another-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.IsNew);
|
||||
|
||||
var audit = _auditRepository.Entries.Single();
|
||||
Assert.Equal("template.updated", audit.EventType);
|
||||
Assert.Equal("another-actor", audit.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_InvalidTemplate_ReturnsError()
|
||||
{
|
||||
// Arrange - template with mismatched braces
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-invalid",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "Hello {{name} - missing closing brace");
|
||||
|
||||
// Act
|
||||
var result = await _service.UpsertAsync(template, "test-actor");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("braces", result.Error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingTemplate_DeletesAndAudits()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-delete", "pack.approval", "en-us");
|
||||
await _templateRepository.UpsertAsync(template);
|
||||
|
||||
// Act
|
||||
var deleted = await _service.DeleteAsync("test-tenant", "tmpl-delete", "delete-actor");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await _templateRepository.GetAsync("test-tenant", "tmpl-delete"));
|
||||
|
||||
var audit = _auditRepository.Entries.Last();
|
||||
Assert.Equal("template.deleted", audit.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonexistentTemplate_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _service.DeleteAsync("test-tenant", "nonexistent", "actor");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-1", "pack.approval", "en-us"));
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-2", "pack.approval", "de-de"));
|
||||
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-3", "risk.alert", "en-us"));
|
||||
|
||||
// Act
|
||||
var results = await _service.ListAsync("test-tenant", new TemplateListOptions
|
||||
{
|
||||
KeyPrefix = "pack.",
|
||||
Locale = "en-us"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("tmpl-1", results[0].TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidTemplate_ReturnsValid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Hello {{name}}, your order {{orderId}} is ready.");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MismatchedBraces_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Hello {{name}, missing close");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("braces"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnclosedEachBlock_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("{{#each items}}{{this}}");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("#each"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SensitiveVariable_ReturnsWarning()
|
||||
{
|
||||
// Act
|
||||
var result = _service.Validate("Your API key is: {{apiKey}}");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("sensitive"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedactionConfig_DefaultMode_ReturnsSafeDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var template = CreateTemplate("tmpl-001", "test.key", "en-us");
|
||||
|
||||
// Act
|
||||
var config = _service.GetRedactionConfig(template);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("safe", config.Mode);
|
||||
Assert.Contains("secret", config.DeniedFields);
|
||||
Assert.Contains("password", config.DeniedFields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedactionConfig_ParanoidMode_RequiresAllowlist()
|
||||
{
|
||||
// Arrange
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "tmpl-paranoid",
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: "test.key",
|
||||
locale: "en-us",
|
||||
body: "Test body",
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["redaction"] = "paranoid",
|
||||
["redaction.allow"] = "name,email"
|
||||
});
|
||||
|
||||
// Act
|
||||
var config = _service.GetRedactionConfig(template);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("paranoid", config.Mode);
|
||||
Assert.Contains("name", config.AllowedFields);
|
||||
Assert.Contains("email", config.AllowedFields);
|
||||
}
|
||||
|
||||
private static NotifyTemplate CreateTemplate(
|
||||
string templateId,
|
||||
string key,
|
||||
string locale,
|
||||
string body = "Test body {{variable}}")
|
||||
{
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: "test-tenant",
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body);
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyTemplateRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyTemplate> _templates = new();
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{template.TenantId}:{template.TemplateId}";
|
||||
_templates[key] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
return Task.FromResult(_templates.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _templates.Values
|
||||
.Where(t => t.TenantId == tenantId)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{templateId}";
|
||||
_templates.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository
|
||||
{
|
||||
public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary<string, string> Metadata)> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(
|
||||
string tenantId,
|
||||
string eventType,
|
||||
string actor,
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add((tenantId, eventType, actor, metadata));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantChannelResolverTests
|
||||
{
|
||||
private static DefaultTenantChannelResolver CreateResolver(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantChannelResolverOptions? options = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantChannelResolverOptions();
|
||||
|
||||
return new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SimpleReference_UsesCurrentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("slack-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
result.ScopedId.Should().Be("tenant-a:slack-alerts");
|
||||
result.IsCrossTenant.Should().BeFalse();
|
||||
result.IsGlobalChannel.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_QualifiedReference_UsesSpecifiedTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowCrossTenant = true };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-b:email-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-b");
|
||||
result.ChannelId.Should().Be("email-channel");
|
||||
result.ScopedId.Should().Be("tenant-b:email-channel");
|
||||
result.IsCrossTenant.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CrossTenantReference_DeniedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowCrossTenant = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-b:email-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Cross-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SameTenantQualified_NotCrossTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("tenant-a:slack-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.IsCrossTenant.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalPrefix_ResolvesToGlobalTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
GlobalPrefix = "@global",
|
||||
GlobalTenant = "system",
|
||||
AllowGlobalChannels = true
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("system");
|
||||
result.ChannelId.Should().Be("broadcast");
|
||||
result.IsGlobalChannel.Should().BeTrue();
|
||||
result.IsCrossTenant.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalChannels_DeniedWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions { AllowGlobalChannels = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Global channels are not allowed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GlobalChannelPattern_MatchesPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
GlobalChannelPatterns = ["system-*", "shared-*"],
|
||||
GlobalTenant = "system"
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("system-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("system");
|
||||
result.IsGlobalChannel.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoTenantContext_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("any-channel");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("No tenant context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithExplicitTenantId_DoesNotRequireContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("slack-alerts", "explicit-tenant");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("explicit-tenant");
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EmptyReference_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve("");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateQualifiedReference_CreatesCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var reference = resolver.CreateQualifiedReference("tenant-xyz", "channel-abc");
|
||||
|
||||
// Assert
|
||||
reference.Should().Be("tenant-xyz:channel-abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SimpleReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("slack-alerts");
|
||||
|
||||
// Assert
|
||||
components.HasTenantPrefix.Should().BeFalse();
|
||||
components.TenantId.Should().BeNull();
|
||||
components.ChannelId.Should().Be("slack-alerts");
|
||||
components.IsGlobal.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_QualifiedReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("tenant-a:email-channel");
|
||||
|
||||
// Assert
|
||||
components.HasTenantPrefix.Should().BeTrue();
|
||||
components.TenantId.Should().Be("tenant-a");
|
||||
components.ChannelId.Should().Be("email-channel");
|
||||
components.IsGlobal.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_GlobalReference_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions { GlobalPrefix = "@global" };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var components = resolver.Parse("@global:broadcast");
|
||||
|
||||
// Assert
|
||||
components.IsGlobal.Should().BeTrue();
|
||||
components.ChannelId.Should().Be("broadcast");
|
||||
components.HasTenantPrefix.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_ValidSimpleReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("slack-alerts");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_ValidQualifiedReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("tenant-a:channel-1");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_InvalidCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("channel@invalid");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidReference_EmptyReference()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = CreateResolver(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = resolver.IsValidReference("");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFallbackReferences_IncludesGlobalFallback()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions
|
||||
{
|
||||
FallbackToGlobal = true,
|
||||
GlobalPrefix = "@global"
|
||||
};
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var fallbacks = resolver.GetFallbackReferences("slack-alerts");
|
||||
|
||||
// Assert
|
||||
fallbacks.Should().HaveCount(2);
|
||||
fallbacks[0].Should().Be("slack-alerts");
|
||||
fallbacks[1].Should().Contain("@global");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFallbackReferences_NoGlobalFallbackWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantChannelResolverOptions { FallbackToGlobal = false };
|
||||
var resolver = CreateResolver(accessor, options);
|
||||
|
||||
// Act
|
||||
var fallbacks = resolver.GetFallbackReferences("slack-alerts");
|
||||
|
||||
// Assert
|
||||
fallbacks.Should().HaveCount(1);
|
||||
fallbacks[0].Should().Be("slack-alerts");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantChannelResolutionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Successful_CreatesSuccessResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TenantChannelResolution.Successful(
|
||||
"tenant-a", "channel-1", "channel-1");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ChannelId.Should().Be("channel-1");
|
||||
result.ScopedId.Should().Be("tenant-a:channel-1");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_CreatesFailedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TenantChannelResolution.Failed("bad-ref", "Invalid channel reference");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.TenantId.Should().BeEmpty();
|
||||
result.ChannelId.Should().BeEmpty();
|
||||
result.Error.Should().Be("Invalid channel reference");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantChannelResolverExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveRequired_ThrowsOnFailure()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var resolver = new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(new TenantChannelResolverOptions()),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
|
||||
// Act
|
||||
var act = () => resolver.ResolveRequired("any-channel");
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Failed to resolve*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequired_ReturnsResultOnSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var resolver = new DefaultTenantChannelResolver(
|
||||
accessor,
|
||||
Options.Create(new TenantChannelResolverOptions()),
|
||||
NullLogger<DefaultTenantChannelResolver>.Instance);
|
||||
|
||||
// Act
|
||||
var result = resolver.ResolveRequired("slack-alerts");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ChannelId.Should().Be("slack-alerts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromHeaders_CreatesValidContext()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromHeaders(
|
||||
tenantId: "tenant-123",
|
||||
actor: "user@test.com",
|
||||
correlationId: "corr-456");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("tenant-123");
|
||||
context.Actor.Should().Be("user@test.com");
|
||||
context.CorrelationId.Should().Be("corr-456");
|
||||
context.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
context.IsSystemContext.Should().BeFalse();
|
||||
context.Claims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromHeaders_UsesDefaultActorWhenEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromHeaders(
|
||||
tenantId: "tenant-123",
|
||||
actor: null,
|
||||
correlationId: null);
|
||||
|
||||
// Assert
|
||||
context.Actor.Should().Be("api");
|
||||
context.CorrelationId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEvent_CreatesContextFromEventSource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.FromEvent(
|
||||
tenantId: "tenant-event",
|
||||
actor: "scheduler",
|
||||
correlationId: "event-corr");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("tenant-event");
|
||||
context.Source.Should().Be(TenantContextSource.EventContext);
|
||||
context.IsSystemContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_CreatesSystemContext()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = TenantContext.System("system-tenant");
|
||||
|
||||
// Assert
|
||||
context.TenantId.Should().Be("system-tenant");
|
||||
context.Actor.Should().Be("system");
|
||||
context.IsSystemContext.Should().BeTrue();
|
||||
context.Source.Should().Be(TenantContextSource.System);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClaim_AddsClaim()
|
||||
{
|
||||
// Arrange
|
||||
var context = TenantContext.FromHeaders("tenant-1", "user", null);
|
||||
|
||||
// Act
|
||||
var result = context.WithClaim("role", "admin");
|
||||
|
||||
// Assert
|
||||
result.Claims.Should().ContainKey("role");
|
||||
result.Claims["role"].Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClaims_AddsMultipleClaims()
|
||||
{
|
||||
// Arrange
|
||||
var context = TenantContext.FromHeaders("tenant-1", "user", null);
|
||||
var claims = new Dictionary<string, string>
|
||||
{
|
||||
["role"] = "admin",
|
||||
["department"] = "engineering"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = context.WithClaims(claims);
|
||||
|
||||
// Assert
|
||||
result.Claims.Should().HaveCount(2);
|
||||
result.Claims["role"].Should().Be("admin");
|
||||
result.Claims["department"].Should().Be("engineering");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantContextAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Context_ReturnsNullWhenNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act & Assert
|
||||
accessor.Context.Should().BeNull();
|
||||
accessor.TenantId.Should().BeNull();
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeSetAndRetrieved()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var context = TenantContext.FromHeaders("tenant-abc", "user", "corr");
|
||||
|
||||
// Act
|
||||
accessor.Context = context;
|
||||
|
||||
// Assert
|
||||
accessor.Context.Should().Be(context);
|
||||
accessor.TenantId.Should().Be("tenant-abc");
|
||||
accessor.HasContext.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredTenantId_ThrowsWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
var act = () => accessor.RequiredTenantId;
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*tenant context*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredTenantId_ReturnsTenantIdWhenSet()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-xyz", "user", null);
|
||||
|
||||
// Act
|
||||
var tenantId = accessor.RequiredTenantId;
|
||||
|
||||
// Assert
|
||||
tenantId.Should().Be("tenant-xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeCleared()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
|
||||
// Act
|
||||
accessor.Context = null;
|
||||
|
||||
// Assert
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
accessor.TenantId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantContextScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scope_SetsContextForDuration()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var originalContext = TenantContext.FromHeaders("original-tenant", "user", null);
|
||||
var scopedContext = TenantContext.FromHeaders("scoped-tenant", "scoped-user", null);
|
||||
accessor.Context = originalContext;
|
||||
|
||||
// Act & Assert
|
||||
using (var scope = new TenantContextScope(accessor, scopedContext))
|
||||
{
|
||||
accessor.TenantId.Should().Be("scoped-tenant");
|
||||
}
|
||||
|
||||
// After scope, original context restored
|
||||
accessor.TenantId.Should().Be("original-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_RestoresNullContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var scopedContext = TenantContext.FromHeaders("scoped-tenant", "user", null);
|
||||
|
||||
// Act & Assert
|
||||
using (var scope = new TenantContextScope(accessor, scopedContext))
|
||||
{
|
||||
accessor.TenantId.Should().Be("scoped-tenant");
|
||||
}
|
||||
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CreatesScope()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
using var scope = TenantContextScope.Create(accessor, "temp-tenant", "temp-actor");
|
||||
|
||||
// Assert
|
||||
accessor.TenantId.Should().Be("temp-tenant");
|
||||
accessor.Context!.Actor.Should().Be("temp-actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSystem_CreatesSystemScope()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
|
||||
// Act
|
||||
using var scope = TenantContextScope.CreateSystem(accessor, "system-tenant");
|
||||
|
||||
// Assert
|
||||
accessor.TenantId.Should().Be("system-tenant");
|
||||
accessor.Context!.IsSystemContext.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantMiddlewareTests
|
||||
{
|
||||
private static (TenantMiddleware Middleware, TenantContextAccessor Accessor) CreateMiddleware(
|
||||
RequestDelegate? next = null,
|
||||
TenantMiddlewareOptions? options = null)
|
||||
{
|
||||
var accessor = new TenantContextAccessor();
|
||||
options ??= new TenantMiddlewareOptions();
|
||||
next ??= _ => Task.CompletedTask;
|
||||
|
||||
var middleware = new TenantMiddleware(
|
||||
next,
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<TenantMiddleware>.Instance);
|
||||
|
||||
return (middleware, accessor);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
string path = "/api/v1/test",
|
||||
Dictionary<string, string>? headers = null,
|
||||
Dictionary<string, string>? query = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = path;
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
context.Request.Headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (query != null)
|
||||
{
|
||||
var queryString = string.Join("&", query.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
context.Request.QueryString = new QueryString($"?{queryString}");
|
||||
}
|
||||
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExtractsTenantFromHeader()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123",
|
||||
["X-StellaOps-Actor"] = "user@test.com",
|
||||
["X-Correlation-Id"] = "corr-456"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
// Note: Context is cleared after middleware completes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_WhenTenantMissingAndRequired()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
RequireTenant = true
|
||||
});
|
||||
|
||||
var context = CreateHttpContext();
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
body.Should().Contain("tenant_missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ContinuesWithoutTenant_WhenNotRequired()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, _) = CreateMiddleware(
|
||||
next: _ => { nextCalled = true; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions { RequireTenant = false });
|
||||
|
||||
var context = CreateHttpContext();
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SkipsExcludedPaths()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(
|
||||
next: _ => { nextCalled = true; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions
|
||||
{
|
||||
RequireTenant = true,
|
||||
ExcludedPaths = ["/healthz", "/metrics"]
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(path: "/healthz");
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_ForInvalidTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant@invalid#chars!"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
body.Should().Contain("tenant_invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RejectsTenantId_TooShort()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
MinTenantIdLength = 5
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "abc"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RejectsTenantId_TooLong()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware(options: new TenantMiddlewareOptions
|
||||
{
|
||||
MaxTenantIdLength = 10
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "very-long-tenant-id-exceeding-max"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExtractsTenantFromQueryParam_ForWebSocket()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
path: "/api/v2/incidents/live",
|
||||
query: new Dictionary<string, string> { ["tenant"] = "websocket-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PrefersHeaderOverQueryParam()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
headers: new Dictionary<string, string> { ["X-StellaOps-Tenant"] = "header-tenant" },
|
||||
query: new Dictionary<string, string> { ["tenant"] = "query-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("header-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_UsesCustomHeaderNames()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(
|
||||
next: ctx => { capturedContext = accessor.Context; return Task.CompletedTask; },
|
||||
options: new TenantMiddlewareOptions
|
||||
{
|
||||
TenantHeader = "X-Custom-Tenant",
|
||||
ActorHeader = "X-Custom-Actor",
|
||||
CorrelationHeader = "X-Custom-Correlation"
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Custom-Tenant"] = "custom-tenant",
|
||||
["X-Custom-Actor"] = "custom-actor",
|
||||
["X-Custom-Correlation"] = "custom-corr"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("custom-tenant");
|
||||
capturedContext.Actor.Should().Be("custom-actor");
|
||||
capturedContext.CorrelationId.Should().Be("custom-corr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsDefaultActor_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Actor.Should().Be("api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_UsesTraceIdentifier_ForCorrelationId_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
context.TraceIdentifier = "test-trace-id";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.CorrelationId.Should().Be("test-trace-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AddsTenantIdToResponseHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, _) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "response-tenant"
|
||||
});
|
||||
|
||||
// Trigger OnStarting callbacks by starting the response
|
||||
await middleware.InvokeAsync(context);
|
||||
await context.Response.StartAsync();
|
||||
|
||||
// Assert
|
||||
context.Response.Headers["X-Tenant-Id"].ToString().Should().Be("response-tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ClearsContextAfterRequest()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, accessor) = CreateMiddleware();
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
accessor.HasContext.Should().BeFalse();
|
||||
accessor.Context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AllowsHyphenAndUnderscore_InTenantId()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123_abc"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.TenantId.Should().Be("tenant-123_abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsSource_ToHttpHeader()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Tenant"] = "tenant-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsSource_ToQueryParameter_ForWebSocket()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var (middleware, accessor) = CreateMiddleware(next: ctx =>
|
||||
{
|
||||
capturedContext = accessor.Context;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var context = CreateHttpContext(
|
||||
path: "/api/live",
|
||||
query: new Dictionary<string, string> { ["tenant"] = "ws-tenant" });
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Source.Should().Be(TenantContextSource.QueryParameter);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantMiddlewareOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new TenantMiddlewareOptions();
|
||||
|
||||
// Assert
|
||||
options.TenantHeader.Should().Be("X-StellaOps-Tenant");
|
||||
options.ActorHeader.Should().Be("X-StellaOps-Actor");
|
||||
options.CorrelationHeader.Should().Be("X-Correlation-Id");
|
||||
options.RequireTenant.Should().BeTrue();
|
||||
options.MinTenantIdLength.Should().Be(1);
|
||||
options.MaxTenantIdLength.Should().Be(128);
|
||||
options.ExcludedPaths.Should().Contain("/healthz");
|
||||
options.ExcludedPaths.Should().Contain("/metrics");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantNotificationEnricherTests
|
||||
{
|
||||
private static DefaultTenantNotificationEnricher CreateEnricher(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantNotificationEnricherOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantNotificationEnricherOptions();
|
||||
timeProvider ??= TimeProvider.System;
|
||||
|
||||
return new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_AddsTenanInfoToPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", "corr-456");
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
var enricher = CreateEnricher(accessor, timeProvider: fakeTime);
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["eventType"] = "test.event",
|
||||
["data"] = new JsonObject { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("_tenant");
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["id"]!.GetValue<string>().Should().Be("tenant-123");
|
||||
tenant["actor"]!.GetValue<string>().Should().Be("user@test.com");
|
||||
tenant["correlationId"]!.GetValue<string>().Should().Be("corr-456");
|
||||
tenant["source"]!.GetValue<string>().Should().Be("HttpHeader");
|
||||
tenant.Should().ContainKey("timestamp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ReturnsUnmodifiedPayloadWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["eventType"] = "test.event"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().NotContainKey("_tenant");
|
||||
result["eventType"]!.GetValue<string>().Should().Be("test.event");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_SkipsWhenIncludeInPayloadDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeInPayload = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().NotContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_UsesCustomPropertyName()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { PayloadPropertyName = "tenantContext" };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("tenantContext");
|
||||
result.Should().NotContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesActorWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeActor = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesCorrelationIdWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", "corr-123");
|
||||
var options = new TenantNotificationEnricherOptions { IncludeCorrelationId = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("correlationId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_ExcludesTimestampWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeTimestamp = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().NotContainKey("timestamp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_IncludesIsSystemForSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system-tenant");
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["isSystem"]!.GetValue<bool>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_IncludesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var context = TenantContext.FromHeaders("tenant-123", "user", null)
|
||||
.WithClaim("role", "admin")
|
||||
.WithClaim("department", "engineering");
|
||||
accessor.Context = context;
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant.Should().ContainKey("claims");
|
||||
var claims = tenant["claims"]!.AsObject();
|
||||
claims["role"]!.GetValue<string>().Should().Be("admin");
|
||||
claims["department"]!.GetValue<string>().Should().Be("engineering");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_WithExplicitContext_UsesProvidedContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("accessor-tenant", "accessor-user", null);
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
var explicitContext = TenantContext.FromHeaders("explicit-tenant", "explicit-user", "explicit-corr");
|
||||
var payload = new JsonObject();
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(payload, explicitContext);
|
||||
|
||||
// Assert
|
||||
var tenant = result["_tenant"]!.AsObject();
|
||||
tenant["id"]!.GetValue<string>().Should().Be("explicit-tenant");
|
||||
tenant["actor"]!.GetValue<string>().Should().Be("explicit-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsEmptyWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsTenantHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user@test.com", "corr-456");
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().ContainKey("X-StellaOps-Tenant");
|
||||
headers["X-StellaOps-Tenant"].Should().Be("tenant-123");
|
||||
headers.Should().ContainKey("X-StellaOps-Actor");
|
||||
headers["X-StellaOps-Actor"].Should().Be("user@test.com");
|
||||
headers.Should().ContainKey("X-Correlation-Id");
|
||||
headers["X-Correlation-Id"].Should().Be("corr-456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_ReturnsEmptyWhenIncludeInHeadersDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions { IncludeInHeaders = false };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHeaders_UsesCustomHeaderNames()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var options = new TenantNotificationEnricherOptions
|
||||
{
|
||||
TenantHeader = "X-Custom-Tenant",
|
||||
ActorHeader = "X-Custom-Actor"
|
||||
};
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
|
||||
// Act
|
||||
var headers = enricher.CreateHeaders();
|
||||
|
||||
// Assert
|
||||
headers.Should().ContainKey("X-Custom-Tenant");
|
||||
headers.Should().ContainKey("X-Custom-Actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ReturnsNullForNullPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(null!);
|
||||
|
||||
// Assert
|
||||
context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ReturnsNullForMissingTenantProperty()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsValidContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "extracted-tenant",
|
||||
["actor"] = "extracted-user",
|
||||
["correlationId"] = "extracted-corr",
|
||||
["source"] = "HttpHeader"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.TenantId.Should().Be("extracted-tenant");
|
||||
context.Actor.Should().Be("extracted-user");
|
||||
context.CorrelationId.Should().Be("extracted-corr");
|
||||
context.Source.Should().Be(TenantContextSource.HttpHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "system",
|
||||
["actor"] = "system",
|
||||
["isSystem"] = true,
|
||||
["source"] = "System"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.IsSystemContext.Should().BeTrue();
|
||||
context.Source.Should().Be(TenantContextSource.System);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_ExtractsClaims()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enricher = CreateEnricher(accessor);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["_tenant"] = new JsonObject
|
||||
{
|
||||
["id"] = "tenant-123",
|
||||
["claims"] = new JsonObject
|
||||
{
|
||||
["role"] = "admin",
|
||||
["tier"] = "premium"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.Claims.Should().HaveCount(2);
|
||||
context.Claims["role"].Should().Be("admin");
|
||||
context.Claims["tier"].Should().Be("premium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContext_UsesCustomPropertyName()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var options = new TenantNotificationEnricherOptions { PayloadPropertyName = "tenantInfo" };
|
||||
var enricher = CreateEnricher(accessor, options);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["tenantInfo"] = new JsonObject
|
||||
{
|
||||
["id"] = "custom-tenant"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = enricher.ExtractContext(payload);
|
||||
|
||||
// Assert
|
||||
context.Should().NotBeNull();
|
||||
context!.TenantId.Should().Be("custom-tenant");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantNotificationEnricherExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnrichFromDictionary_CreatesEnrichedPayload()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var enricher = new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(new TenantNotificationEnricherOptions()),
|
||||
TimeProvider.System);
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["eventType"] = "test.event",
|
||||
["value"] = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.EnrichFromDictionary(data);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("eventType");
|
||||
result.Should().ContainKey("value");
|
||||
result.Should().ContainKey("_tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichAndSerialize_ReturnsJsonString()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-123", "user", null);
|
||||
var enricher = new DefaultTenantNotificationEnricher(
|
||||
accessor,
|
||||
Options.Create(new TenantNotificationEnricherOptions()),
|
||||
TimeProvider.System);
|
||||
|
||||
var payload = new JsonObject { ["data"] = "test" };
|
||||
|
||||
// Act
|
||||
var result = enricher.EnrichAndSerialize(payload);
|
||||
|
||||
// Assert
|
||||
result.Should().Contain("\"_tenant\"");
|
||||
result.Should().Contain("\"tenant-123\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Tenancy;
|
||||
|
||||
public sealed class TenantRlsEnforcerTests
|
||||
{
|
||||
private static DefaultTenantRlsEnforcer CreateEnforcer(
|
||||
ITenantContextAccessor? accessor = null,
|
||||
TenantRlsOptions? options = null)
|
||||
{
|
||||
accessor ??= new TenantContextAccessor();
|
||||
options ??= new TenantRlsOptions();
|
||||
|
||||
return new DefaultTenantRlsEnforcer(
|
||||
accessor,
|
||||
Options.Create(options),
|
||||
NullLogger<DefaultTenantRlsEnforcer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsSameTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-a", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-a");
|
||||
result.IsSystemAccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesDifferentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.DenialReason.Should().Contain("tenant-a");
|
||||
result.DenialReason.Should().Contain("tenant-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsSystemContextBypass()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AllowSystemBypass = true };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.IsSystemAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesSystemContextWhenBypassDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AllowSystemBypass = false };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsAdminTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("admin", "admin-user", null);
|
||||
var options = new TenantRlsOptions { AdminTenantPatterns = ["^admin$"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.IsSystemAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsGlobalResourceTypes()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantRlsOptions { GlobalResourceTypes = ["system-template"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"system-template", "template-123", "system", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_AllowsAllWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var options = new TenantRlsOptions { Enabled = false };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessAsync_DeniesWhenNoContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var result = await enforcer.ValidateAccessAsync(
|
||||
"notification", "notif-123", "tenant-a", RlsOperation.Read);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.DenialReason.Should().Contain("context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureAccessAsync_ThrowsOnDenial()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-a", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var act = async () => await enforcer.EnsureAccessAsync(
|
||||
"notification", "notif-123", "tenant-b", RlsOperation.Update);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<TenantAccessDeniedException>()
|
||||
.Where(ex => ex.TenantId == "tenant-a" &&
|
||||
ex.ResourceTenantId == "tenant-b" &&
|
||||
ex.ResourceType == "notification" &&
|
||||
ex.Operation == RlsOperation.Update);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentTenantId_ReturnsCurrentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-xyz", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var tenantId = enforcer.GetCurrentTenantId();
|
||||
|
||||
// Assert
|
||||
tenantId.Should().Be("tenant-xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSystemAccess_ReturnsTrueForSystemContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var hasAccess = enforcer.HasSystemAccess();
|
||||
|
||||
// Assert
|
||||
hasAccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSystemAccess_ReturnsFalseForRegularContext()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("regular-tenant", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var hasAccess = enforcer.HasSystemAccess();
|
||||
|
||||
// Assert
|
||||
hasAccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScopedId_CreatesTenantPrefixedId()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var scopedId = enforcer.CreateScopedId("resource-123");
|
||||
|
||||
// Assert
|
||||
scopedId.Should().Be("tenant-abc:resource-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResourceId_ExtractsResourcePart()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var resourceId = enforcer.ExtractResourceId("tenant-abc:resource-123");
|
||||
|
||||
// Assert
|
||||
resourceId.Should().Be("resource-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractResourceId_ReturnsNullForInvalidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var resourceId = enforcer.ExtractResourceId("no-separator-here");
|
||||
|
||||
// Assert
|
||||
resourceId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsTrueForSameTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("tenant-abc:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsFalseForDifferentTenant()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.FromHeaders("tenant-abc", "user", null);
|
||||
var enforcer = CreateEnforcer(accessor);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("tenant-xyz:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScopedId_ReturnsTrueForSystemAccess()
|
||||
{
|
||||
// Arrange
|
||||
var accessor = new TenantContextAccessor();
|
||||
accessor.Context = TenantContext.System("system");
|
||||
var options = new TenantRlsOptions { AdminTenantPatterns = ["^system$"] };
|
||||
var enforcer = CreateEnforcer(accessor, options);
|
||||
|
||||
// Act
|
||||
var isValid = enforcer.ValidateScopedId("any-tenant:resource-123");
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RlsValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allowed_CreatesAllowedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = RlsValidationResult.Allowed("tenant-a", "tenant-a");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-a");
|
||||
result.DenialReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_CreatesDeniedResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = RlsValidationResult.Denied("tenant-a", "tenant-b", "Cross-tenant access denied");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.TenantId.Should().Be("tenant-a");
|
||||
result.ResourceTenantId.Should().Be("tenant-b");
|
||||
result.DenialReason.Should().Be("Cross-tenant access denied");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAccessDeniedExceptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsAllProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exception = new TenantAccessDeniedException(
|
||||
"tenant-a", "tenant-b", "notification", "notif-123", RlsOperation.Update);
|
||||
|
||||
// Assert
|
||||
exception.TenantId.Should().Be("tenant-a");
|
||||
exception.ResourceTenantId.Should().Be("tenant-b");
|
||||
exception.ResourceType.Should().Be("notification");
|
||||
exception.ResourceId.Should().Be("notif-123");
|
||||
exception.Operation.Should().Be(RlsOperation.Update);
|
||||
exception.Message.Should().Contain("tenant-a");
|
||||
exception.Message.Should().Contain("tenant-b");
|
||||
exception.Message.Should().Contain("notification/notif-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for acknowledgment bridge.
|
||||
/// </summary>
|
||||
public sealed class AckBridgeTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IEscalationEngine> _escalationEngine;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly AckBridgeOptions _options;
|
||||
private readonly AckBridge _bridge;
|
||||
|
||||
public AckBridgeTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_escalationEngine = new Mock<IEscalationEngine>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
|
||||
_options = new AckBridgeOptions
|
||||
{
|
||||
AckBaseUrl = "https://notify.example.com",
|
||||
SigningKey = "test-signing-key-for-unit-tests",
|
||||
DefaultTokenExpiry = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
_bridge = new AckBridge(
|
||||
_escalationEngine.Object,
|
||||
_incidentManager.Object,
|
||||
null,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<AckBridge>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAckLink_CreatesValidUrl()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
link.Should().StartWith("https://notify.example.com/ack?token=");
|
||||
link.Should().Contain("token=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithValidToken_ReturnsValid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
result.TargetId.Should().Be("user-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithExpiredToken_ReturnsInvalid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
TimeSpan.FromMinutes(30));
|
||||
|
||||
var token = ExtractToken(link);
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("expired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithTamperedToken_ReturnsInvalid()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var tamperedToken = token.Substring(0, token.Length - 5) + "XXXXX";
|
||||
|
||||
var result = await _bridge.ValidateTokenAsync(tamperedToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_WithMalformedToken_ReturnsInvalid()
|
||||
{
|
||||
var result = await _bridge.ValidateTokenAsync("not-a-valid-token");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid token format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithSignedLink_ProcessesSuccessfully()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = token,
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithInvalidToken_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = "invalid-token",
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithDirectIds_ProcessesSuccessfully()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "user-1", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithExternalId_ResolvesMapping()
|
||||
{
|
||||
var escalationState = new EscalationState
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
IncidentId = "incident-1",
|
||||
PolicyId = "policy-1",
|
||||
Status = EscalationStatus.Acknowledged
|
||||
};
|
||||
|
||||
_escalationEngine.Setup(x => x.ProcessAcknowledgmentAsync(
|
||||
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(escalationState);
|
||||
|
||||
_incidentManager.Setup(x => x.AcknowledgeAsync(
|
||||
"tenant-1", "incident-1", "pagerduty", It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Register the external ID mapping
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-alert-123", "tenant-1", "incident-1");
|
||||
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.PagerDuty,
|
||||
ExternalId = "pd-alert-123",
|
||||
AcknowledgedBy = "pagerduty"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithUnknownExternalId_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.PagerDuty,
|
||||
ExternalId = "unknown-external-id",
|
||||
AcknowledgedBy = "pagerduty"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Unknown external ID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAck_WithMissingIds_ReturnsFailed()
|
||||
{
|
||||
var request = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
AcknowledgedBy = "user-1"
|
||||
};
|
||||
|
||||
var result = await _bridge.ProcessAckAsync(request);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Could not resolve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAckLink_UsesDefaultExpiry()
|
||||
{
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1");
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ExpiresAt.Should().BeCloseTo(
|
||||
_timeProvider.GetUtcNow().Add(_options.DefaultTokenExpiry),
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterExternalId_AllowsMultipleMappings()
|
||||
{
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-1", "tenant-1", "incident-1");
|
||||
_bridge.RegisterExternalId(AckSource.OpsGenie, "og-1", "tenant-1", "incident-2");
|
||||
_bridge.RegisterExternalId(AckSource.PagerDuty, "pd-2", "tenant-2", "incident-3");
|
||||
|
||||
// Verify by trying to resolve (indirectly through ProcessAckAsync)
|
||||
// This is validated by the ProcessAck_WithExternalId_ResolvesMapping test
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateToken_ReturnsExpiresAt()
|
||||
{
|
||||
var expiry = TimeSpan.FromHours(2);
|
||||
var link = await _bridge.GenerateAckLinkAsync(
|
||||
"tenant-1",
|
||||
"incident-1",
|
||||
"user-1",
|
||||
expiry);
|
||||
|
||||
var token = ExtractToken(link);
|
||||
var result = await _bridge.ValidateTokenAsync(token);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ExpiresAt.Should().BeCloseTo(
|
||||
_timeProvider.GetUtcNow().Add(expiry),
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private static string ExtractToken(string link)
|
||||
{
|
||||
var uri = new Uri(link);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
return query["token"] ?? throw new InvalidOperationException("Token not found in URL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for escalation engine.
|
||||
/// </summary>
|
||||
public sealed class EscalationEngineTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IEscalationPolicyService> _policyService;
|
||||
private readonly Mock<IOnCallScheduleService> _scheduleService;
|
||||
private readonly Mock<IIncidentManager> _incidentManager;
|
||||
private readonly EscalationEngine _engine;
|
||||
|
||||
public EscalationEngineTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_policyService = new Mock<IEscalationPolicyService>();
|
||||
_scheduleService = new Mock<IOnCallScheduleService>();
|
||||
_incidentManager = new Mock<IIncidentManager>();
|
||||
|
||||
_engine = new EscalationEngine(
|
||||
_policyService.Object,
|
||||
_scheduleService.Object,
|
||||
_incidentManager.Object,
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<EscalationEngine>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_WithValidPolicy_ReturnsStartedState()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.IncidentId.Should().Be("incident-1");
|
||||
result.PolicyId.Should().Be("policy-1");
|
||||
result.Status.Should().Be(EscalationStatus.InProgress);
|
||||
result.CurrentLevel.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_WithNonexistentPolicy_ReturnsFailedState()
|
||||
{
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EscalationPolicy?)null);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "nonexistent");
|
||||
|
||||
result.Status.Should().Be(EscalationStatus.Failed);
|
||||
result.ErrorMessage.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEscalationState_AfterStart_ReturnsState()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEscalationState_WhenNotStarted_ReturnsNull()
|
||||
{
|
||||
var result = await _engine.GetEscalationStateAsync("tenant-1", "nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAcknowledgment_StopsEscalation()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(EscalationStatus.Acknowledged);
|
||||
result.AcknowledgedBy.Should().Be("user-1");
|
||||
result.AcknowledgedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAcknowledgment_WhenNotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _engine.ProcessAcknowledgmentAsync("tenant-1", "nonexistent", "user-1");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalate_MovesToNextLevel()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Manual escalation", "admin");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.CurrentLevel.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalate_AtMaxLevel_StartsNewCycle()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
await _engine.EscalateAsync("tenant-1", "incident-1", "First escalation", "admin");
|
||||
|
||||
var result = await _engine.EscalateAsync("tenant-1", "incident-1", "Second escalation", "admin");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.CurrentLevel.Should().Be(1);
|
||||
result.CycleCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopEscalation_SetsResolvedStatus()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
var result = await _engine.StopEscalationAsync("tenant-1", "incident-1", "Incident resolved", "admin");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var state = await _engine.GetEscalationStateAsync("tenant-1", "incident-1");
|
||||
state!.Status.Should().Be(EscalationStatus.Resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListActiveEscalations_ReturnsOnlyInProgress()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-2", "policy-1");
|
||||
await _engine.ProcessAcknowledgmentAsync("tenant-1", "incident-1", "user-1");
|
||||
|
||||
var result = await _engine.ListActiveEscalationsAsync("tenant-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].IncidentId.Should().Be("incident-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPendingEscalations_EscalatesOverdueItems()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
// Advance time past the escalation delay (5 minutes)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
var actions = await _engine.ProcessPendingEscalationsAsync();
|
||||
|
||||
actions.Should().NotBeEmpty();
|
||||
actions[0].ActionType.Should().Be("Escalate");
|
||||
actions[0].IncidentId.Should().Be("incident-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPendingEscalations_DoesNotEscalateBeforeDelay()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
// Advance time but not past the delay (5 minutes)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(3));
|
||||
|
||||
var actions = await _engine.ProcessPendingEscalationsAsync();
|
||||
|
||||
actions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_ResolvesOnCallTargets()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new OnCallUser { UserId = "user-1", UserName = "User One", Email = "user1@example.com" },
|
||||
new OnCallUser { UserId = "user-2", UserName = "User Two", Email = "user2@example.com" }
|
||||
]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.ResolvedTargets.Should().HaveCount(2);
|
||||
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-1");
|
||||
result.ResolvedTargets.Should().Contain(t => t.UserId == "user-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartEscalation_RecordsHistoryEntry()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
_policyService.Setup(x => x.GetPolicyAsync("tenant-1", "policy-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
|
||||
_scheduleService.Setup(x => x.GetOnCallAsync("tenant-1", "schedule-1", It.IsAny<DateTimeOffset?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new OnCallUser { UserId = "user-1", UserName = "Test User" }]);
|
||||
|
||||
var result = await _engine.StartEscalationAsync("tenant-1", "incident-1", "policy-1");
|
||||
|
||||
result.History.Should().HaveCount(1);
|
||||
result.History[0].Action.Should().Be("Started");
|
||||
result.History[0].Level.Should().Be(1);
|
||||
}
|
||||
|
||||
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
|
||||
{
|
||||
PolicyId = policyId,
|
||||
TenantId = tenantId,
|
||||
Name = "Default Escalation",
|
||||
IsDefault = true,
|
||||
Levels =
|
||||
[
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 1,
|
||||
DelayMinutes = 5,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.OnCallSchedule,
|
||||
TargetId = "schedule-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 2,
|
||||
DelayMinutes = 15,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.User,
|
||||
TargetId = "manager-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for escalation policy service.
|
||||
/// </summary>
|
||||
public sealed class EscalationPolicyServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryEscalationPolicyService _service;
|
||||
|
||||
public EscalationPolicyServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new InMemoryEscalationPolicyService(
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<InMemoryEscalationPolicyService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPolicies_WhenEmpty_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _service.ListPoliciesAsync("tenant-1");
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_CreatesNewPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
|
||||
var result = await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
result.PolicyId.Should().Be("policy-1");
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
result.Name.Should().Be("Default Escalation");
|
||||
result.Levels.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_UpdatesExistingPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var updated = policy with { Name = "Updated Policy" };
|
||||
var result = await _service.UpsertPolicyAsync(updated, "admin");
|
||||
|
||||
result.Name.Should().Be("Updated Policy");
|
||||
|
||||
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
retrieved!.Name.Should().Be("Updated Policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPolicy_WhenExists_ReturnsPolicy()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPolicy_WhenNotExists_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetPolicyAsync("tenant-1", "nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicy_WhenExists_ReturnsTrue()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.DeletePolicyAsync("tenant-1", "policy-1", "admin");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var retrieved = await _service.GetPolicyAsync("tenant-1", "policy-1");
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePolicy_WhenNotExists_ReturnsFalse()
|
||||
{
|
||||
var result = await _service.DeletePolicyAsync("tenant-1", "nonexistent", "admin");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDefaultPolicy_ReturnsFirstDefaultPolicy()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with { IsDefault = true };
|
||||
var policy3 = CreateTestPolicy("tenant-1", "policy-3") with { IsDefault = true };
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
await _service.UpsertPolicyAsync(policy3, "admin");
|
||||
|
||||
var result = await _service.GetDefaultPolicyAsync("tenant-1");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.IsDefault.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDefaultPolicy_WhenNoneDefault_ReturnsNull()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1") with { IsDefault = false };
|
||||
await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
var result = await _service.GetDefaultPolicyAsync("tenant-1");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_FiltersByEventKind()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
|
||||
{
|
||||
EventKindFilter = ["scan.*", "vulnerability.*"]
|
||||
};
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
|
||||
{
|
||||
EventKindFilter = ["compliance.*"]
|
||||
};
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "scan.completed", null);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_FiltersBySeverity()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1") with
|
||||
{
|
||||
SeverityFilter = ["critical", "high"]
|
||||
};
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2") with
|
||||
{
|
||||
SeverityFilter = ["low"]
|
||||
};
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "incident.created", "critical");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].PolicyId.Should().Be("policy-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchingPolicies_ReturnsAllWhenNoFilters()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
|
||||
var policy2 = CreateTestPolicy("tenant-1", "policy-2");
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var result = await _service.FindMatchingPoliciesAsync("tenant-1", "any.event", null);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPolicies_IsolatesByTenant()
|
||||
{
|
||||
var policy1 = CreateTestPolicy("tenant-1", "policy-1");
|
||||
var policy2 = CreateTestPolicy("tenant-2", "policy-2");
|
||||
|
||||
await _service.UpsertPolicyAsync(policy1, "admin");
|
||||
await _service.UpsertPolicyAsync(policy2, "admin");
|
||||
|
||||
var tenant1Policies = await _service.ListPoliciesAsync("tenant-1");
|
||||
var tenant2Policies = await _service.ListPoliciesAsync("tenant-2");
|
||||
|
||||
tenant1Policies.Should().HaveCount(1);
|
||||
tenant1Policies[0].PolicyId.Should().Be("policy-1");
|
||||
|
||||
tenant2Policies.Should().HaveCount(1);
|
||||
tenant2Policies[0].PolicyId.Should().Be("policy-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicy_SetsTimestamps()
|
||||
{
|
||||
var policy = CreateTestPolicy("tenant-1", "policy-1");
|
||||
|
||||
var result = await _service.UpsertPolicyAsync(policy, "admin");
|
||||
|
||||
result.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
result.UpdatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private static EscalationPolicy CreateTestPolicy(string tenantId, string policyId) => new()
|
||||
{
|
||||
PolicyId = policyId,
|
||||
TenantId = tenantId,
|
||||
Name = "Default Escalation",
|
||||
IsDefault = true,
|
||||
Levels =
|
||||
[
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 1,
|
||||
DelayMinutes = 5,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.OnCallSchedule,
|
||||
TargetId = "schedule-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
new EscalationLevel
|
||||
{
|
||||
Order = 2,
|
||||
DelayMinutes = 15,
|
||||
Targets =
|
||||
[
|
||||
new EscalationTarget
|
||||
{
|
||||
TargetType = EscalationTargetType.User,
|
||||
TargetId = "manager-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Tests.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for inbox channel adapters.
|
||||
/// </summary>
|
||||
public sealed class InboxChannelTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InAppInboxChannel _inboxChannel;
|
||||
private readonly CliNotificationChannel _cliChannel;
|
||||
|
||||
public InboxChannelTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_inboxChannel = new InAppInboxChannel(
|
||||
null,
|
||||
_timeProvider,
|
||||
NullLogger<InAppInboxChannel>.Instance);
|
||||
|
||||
_cliChannel = new CliNotificationChannel(
|
||||
_timeProvider,
|
||||
NullLogger<CliNotificationChannel>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_SendAsync_CreatesNotification()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.SendAsync(notification);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_ReturnsNotifications()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _inboxChannel.SendAsync(notification);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersUnread()
|
||||
{
|
||||
var notif1 = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
var notif2 = CreateTestNotification("tenant-1", "user-1", "notif-2");
|
||||
|
||||
await _inboxChannel.SendAsync(notif1);
|
||||
await _inboxChannel.SendAsync(notif2);
|
||||
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1", new InboxQuery { IsRead = false });
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersByType()
|
||||
{
|
||||
var incident = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
Type = InboxNotificationType.Incident
|
||||
};
|
||||
var system = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
Type = InboxNotificationType.System
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(incident);
|
||||
await _inboxChannel.SendAsync(system);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { Type = InboxNotificationType.Incident });
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_FiltersByMinPriority()
|
||||
{
|
||||
var low = CreateTestNotification("tenant-1", "user-1", "notif-1") with { Priority = InboxPriority.Low };
|
||||
var high = CreateTestNotification("tenant-1", "user-1", "notif-2") with { Priority = InboxPriority.High };
|
||||
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-3") with { Priority = InboxPriority.Urgent };
|
||||
|
||||
await _inboxChannel.SendAsync(low);
|
||||
await _inboxChannel.SendAsync(high);
|
||||
await _inboxChannel.SendAsync(urgent);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { MinPriority = InboxPriority.High });
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().OnlyContain(n => n.Priority >= InboxPriority.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_ExcludesExpired()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_IncludesExpiredWhenRequested()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { IncludeExpired = true });
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_RespectsLimit()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", $"notif-{i}"));
|
||||
}
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1",
|
||||
new InboxQuery { Limit = 5 });
|
||||
|
||||
result.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkReadAsync_MarksNotificationAsRead()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _inboxChannel.SendAsync(notification);
|
||||
|
||||
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
|
||||
var list = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
list[0].IsRead.Should().BeTrue();
|
||||
list[0].ReadAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkReadAsync_ReturnsFalseForNonexistent()
|
||||
{
|
||||
var result = await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "nonexistent");
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_MarkAllReadAsync_MarksAllAsRead()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
|
||||
|
||||
var result = await _inboxChannel.MarkAllReadAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(3);
|
||||
|
||||
var unread = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
unread.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_GetUnreadCountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-3"));
|
||||
await _inboxChannel.MarkReadAsync("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_GetUnreadCountAsync_ExcludesExpired()
|
||||
{
|
||||
var active = CreateTestNotification("tenant-1", "user-1", "notif-1") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(1)
|
||||
};
|
||||
var expired = CreateTestNotification("tenant-1", "user-1", "notif-2") with
|
||||
{
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
};
|
||||
|
||||
await _inboxChannel.SendAsync(active);
|
||||
await _inboxChannel.SendAsync(expired);
|
||||
|
||||
var result = await _inboxChannel.GetUnreadCountAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_IsolatesByTenantAndUser()
|
||||
{
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-1", "notif-1"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-1", "user-2", "notif-2"));
|
||||
await _inboxChannel.SendAsync(CreateTestNotification("tenant-2", "user-1", "notif-3"));
|
||||
|
||||
var tenant1User1 = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
var tenant1User2 = await _inboxChannel.ListAsync("tenant-1", "user-2");
|
||||
var tenant2User1 = await _inboxChannel.ListAsync("tenant-2", "user-1");
|
||||
|
||||
tenant1User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-1");
|
||||
tenant1User2.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-2");
|
||||
tenant2User1.Should().HaveCount(1).And.Contain(n => n.NotificationId == "notif-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_ListAsync_SortsByPriorityAndCreatedAt()
|
||||
{
|
||||
var low = CreateTestNotification("tenant-1", "user-1", "notif-low") with { Priority = InboxPriority.Low };
|
||||
await _inboxChannel.SendAsync(low);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var high = CreateTestNotification("tenant-1", "user-1", "notif-high") with { Priority = InboxPriority.High };
|
||||
await _inboxChannel.SendAsync(high);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var urgent = CreateTestNotification("tenant-1", "user-1", "notif-urgent") with { Priority = InboxPriority.Urgent };
|
||||
await _inboxChannel.SendAsync(urgent);
|
||||
|
||||
var result = await _inboxChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result[0].NotificationId.Should().Be("notif-urgent");
|
||||
result[1].NotificationId.Should().Be("notif-high");
|
||||
result[2].NotificationId.Should().Be("notif-low");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_SendAsync_CreatesNotification()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
|
||||
var result = await _cliChannel.SendAsync(notification);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.NotificationId.Should().Be("notif-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_ListAsync_ReturnsNotifications()
|
||||
{
|
||||
var notification = CreateTestNotification("tenant-1", "user-1", "notif-1");
|
||||
await _cliChannel.SendAsync(notification);
|
||||
|
||||
var result = await _cliChannel.ListAsync("tenant-1", "user-1");
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_FormatForCli_FormatsCorrectly()
|
||||
{
|
||||
var notification = new InboxNotification
|
||||
{
|
||||
NotificationId = "notif-1",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Critical Alert",
|
||||
Body = "Server down",
|
||||
Priority = InboxPriority.Urgent,
|
||||
IsRead = false,
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var formatted = CliNotificationChannel.FormatForCli(notification);
|
||||
|
||||
formatted.Should().Contain("[!!!]");
|
||||
formatted.Should().Contain("●");
|
||||
formatted.Should().Contain("Critical Alert");
|
||||
formatted.Should().Contain("Server down");
|
||||
formatted.Should().Contain("2025-01-15");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_FormatForCli_ShowsReadMarker()
|
||||
{
|
||||
var notification = new InboxNotification
|
||||
{
|
||||
NotificationId = "notif-1",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-1",
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Alert",
|
||||
Body = "Details",
|
||||
Priority = InboxPriority.Normal,
|
||||
IsRead = true,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var formatted = CliNotificationChannel.FormatForCli(notification);
|
||||
|
||||
formatted.Should().NotContain("●");
|
||||
formatted.Should().Contain("[*]");
|
||||
}
|
||||
|
||||
private static InboxNotification CreateTestNotification(string tenantId, string userId, string notificationId) => new()
|
||||
{
|
||||
NotificationId = notificationId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Type = InboxNotificationType.Incident,
|
||||
Title = "Test Alert",
|
||||
Body = "This is a test notification",
|
||||
Priority = InboxPriority.Normal
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Incident list query parameters.
|
||||
/// </summary>
|
||||
public sealed record IncidentListQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by status (open, acknowledged, resolved).
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by event kind prefix.
|
||||
/// </summary>
|
||||
public string? EventKindPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents after this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents before this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor for pagination.
|
||||
/// </summary>
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident response DTO.
|
||||
/// </summary>
|
||||
public sealed record IncidentResponse
|
||||
{
|
||||
public required string IncidentId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventKind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required int EventCount { get; init; }
|
||||
public required DateTimeOffset FirstOccurrence { get; init; }
|
||||
public required DateTimeOffset LastOccurrence { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident list response with pagination.
|
||||
/// </summary>
|
||||
public sealed record IncidentListResponse
|
||||
{
|
||||
public required List<IncidentResponse> Incidents { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the acknowledgement.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentResolveRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor resolving the incident.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery history item for an incident.
|
||||
/// </summary>
|
||||
public sealed record DeliveryHistoryItem
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string ChannelName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int Attempts { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for acknowledging a pack approval decision.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Acknowledgement token from the notification.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[JsonPropertyName("ackToken")]
|
||||
public string AckToken { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval decision: "approved" or "rejected".
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string? Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment for audit trail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity acknowledging the approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,44 +2,87 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for pack approval events from Task Runner.
|
||||
/// See: docs/notifications/pack-approvals-contract.md
|
||||
/// </summary>
|
||||
public sealed class PackApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique event identifier for deduplication.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp in UTC (ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package identifier in PURL format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata (id and version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy")]
|
||||
public PackApprovalPolicy? Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current approval state: pending, approved, rejected, hold, expired.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Identity that triggered the event.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resumeToken")]
|
||||
public string? ResumeToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary for notifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom key-value metadata labels.
|
||||
/// </summary>
|
||||
[JsonPropertyName("labels")]
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata associated with a pack approval.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a notification rule.
|
||||
/// </summary>
|
||||
public sealed record RuleCreateRequest
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public required RuleMatchRequest Match { get; init; }
|
||||
public required List<RuleActionRequest> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing rule.
|
||||
/// </summary>
|
||||
public sealed record RuleUpdateRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public List<RuleActionRequest>? Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; init; }
|
||||
public List<string>? Namespaces { get; init; }
|
||||
public List<string>? Repositories { get; init; }
|
||||
public List<string>? Digests { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public List<string>? ComponentPurls { get; init; }
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string>? Verdicts { get; init; }
|
||||
public bool? KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action configuration.
|
||||
/// </summary>
|
||||
public sealed record RuleActionRequest
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; } // ISO 8601 duration
|
||||
public string? Locale { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule response DTO.
|
||||
/// </summary>
|
||||
public sealed record RuleResponse
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public required RuleMatchResponse Match { get; init; }
|
||||
public required List<RuleActionResponse> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match response.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchResponse
|
||||
{
|
||||
public List<string> EventKinds { get; init; } = [];
|
||||
public List<string> Namespaces { get; init; } = [];
|
||||
public List<string> Repositories { get; init; } = [];
|
||||
public List<string> Digests { get; init; } = [];
|
||||
public List<string> Labels { get; init; } = [];
|
||||
public List<string> ComponentPurls { get; init; } = [];
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string> Verdicts { get; init; } = [];
|
||||
public bool KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action response.
|
||||
/// </summary>
|
||||
public sealed record RuleActionResponse
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to preview a template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Template ID to preview (mutually exclusive with TemplateBody).
|
||||
/// </summary>
|
||||
public string? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw template body to preview (mutually exclusive with TemplateId).
|
||||
/// </summary>
|
||||
public string? TemplateBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample event payload for rendering.
|
||||
/// </summary>
|
||||
public JsonObject? SamplePayload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for context.
|
||||
/// </summary>
|
||||
public string? EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample attributes.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? SampleAttributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format override.
|
||||
/// </summary>
|
||||
public string? OutputFormat { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from template preview.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Rendered body content.
|
||||
/// </summary>
|
||||
public required string RenderedBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rendered subject (if applicable).
|
||||
/// </summary>
|
||||
public string? RenderedSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for deduplication.
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format used.
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (if any).
|
||||
/// </summary>
|
||||
public List<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a template.
|
||||
/// </summary>
|
||||
public sealed record TemplateCreateRequest
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? RenderMode { get; init; }
|
||||
public string? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template response DTO.
|
||||
/// </summary>
|
||||
public sealed record TemplateResponse
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public required string RenderMode { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template list query parameters.
|
||||
/// </summary>
|
||||
public sealed record TemplateListQuery
|
||||
{
|
||||
public string? KeyPrefix { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for escalation management.
|
||||
/// </summary>
|
||||
public static class EscalationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps escalation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapEscalationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Escalation Policies
|
||||
var policies = app.MapGroup("/api/v2/escalation-policies")
|
||||
.WithTags("Escalation Policies")
|
||||
.WithOpenApi();
|
||||
|
||||
policies.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("ListEscalationPolicies")
|
||||
.WithSummary("List escalation policies");
|
||||
|
||||
policies.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("GetEscalationPolicy")
|
||||
.WithSummary("Get an escalation policy");
|
||||
|
||||
policies.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("CreateEscalationPolicy")
|
||||
.WithSummary("Create an escalation policy");
|
||||
|
||||
policies.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("UpdateEscalationPolicy")
|
||||
.WithSummary("Update an escalation policy");
|
||||
|
||||
policies.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("DeleteEscalationPolicy")
|
||||
.WithSummary("Delete an escalation policy");
|
||||
|
||||
// On-Call Schedules
|
||||
var schedules = app.MapGroup("/api/v2/oncall-schedules")
|
||||
.WithTags("On-Call Schedules")
|
||||
.WithOpenApi();
|
||||
|
||||
schedules.MapGet("/", ListSchedulesAsync)
|
||||
.WithName("ListOnCallSchedules")
|
||||
.WithSummary("List on-call schedules");
|
||||
|
||||
schedules.MapGet("/{scheduleId}", GetScheduleAsync)
|
||||
.WithName("GetOnCallSchedule")
|
||||
.WithSummary("Get an on-call schedule");
|
||||
|
||||
schedules.MapPost("/", CreateScheduleAsync)
|
||||
.WithName("CreateOnCallSchedule")
|
||||
.WithSummary("Create an on-call schedule");
|
||||
|
||||
schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
|
||||
.WithName("UpdateOnCallSchedule")
|
||||
.WithSummary("Update an on-call schedule");
|
||||
|
||||
schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
|
||||
.WithName("DeleteOnCallSchedule")
|
||||
.WithSummary("Delete an on-call schedule");
|
||||
|
||||
schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
|
||||
.WithName("GetCurrentOnCall")
|
||||
.WithSummary("Get current on-call users");
|
||||
|
||||
schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
|
||||
.WithName("CreateOnCallOverride")
|
||||
.WithSummary("Create an on-call override");
|
||||
|
||||
schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
|
||||
.WithName("DeleteOnCallOverride")
|
||||
.WithSummary("Delete an on-call override");
|
||||
|
||||
// Active Escalations
|
||||
var escalations = app.MapGroup("/api/v2/escalations")
|
||||
.WithTags("Escalations")
|
||||
.WithOpenApi();
|
||||
|
||||
escalations.MapGet("/", ListActiveEscalationsAsync)
|
||||
.WithName("ListActiveEscalations")
|
||||
.WithSummary("List active escalations");
|
||||
|
||||
escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
|
||||
.WithName("GetEscalationState")
|
||||
.WithSummary("Get escalation state for an incident");
|
||||
|
||||
escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
|
||||
.WithName("StartEscalation")
|
||||
.WithSummary("Start escalation for an incident");
|
||||
|
||||
escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
|
||||
.WithName("ManualEscalate")
|
||||
.WithSummary("Manually escalate to next level");
|
||||
|
||||
escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
|
||||
.WithName("StopEscalation")
|
||||
.WithSummary("Stop escalation");
|
||||
|
||||
// Ack Bridge
|
||||
var ack = app.MapGroup("/api/v2/ack")
|
||||
.WithTags("Acknowledgment")
|
||||
.WithOpenApi();
|
||||
|
||||
ack.MapPost("/", ProcessAckAsync)
|
||||
.WithName("ProcessAck")
|
||||
.WithSummary("Process an acknowledgment");
|
||||
|
||||
ack.MapGet("/", ProcessAckLinkAsync)
|
||||
.WithName("ProcessAckLink")
|
||||
.WithSummary("Process an acknowledgment link");
|
||||
|
||||
ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
|
||||
.WithName("PagerDutyWebhook")
|
||||
.WithSummary("Process PagerDuty webhook");
|
||||
|
||||
ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
|
||||
.WithName("OpsGenieWebhook")
|
||||
.WithSummary("Process OpsGenie webhook");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
#region Policy Endpoints
|
||||
|
||||
private static async Task<IResult> ListPoliciesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var policies = await policyService.ListPoliciesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var policy = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
return policy is null
|
||||
? Results.NotFound(new { error = $"Policy '{policyId}' not found." })
|
||||
: Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicyAsync(
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Policy name is required." });
|
||||
}
|
||||
|
||||
if (request.Levels is null || request.Levels.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one escalation level is required." });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId);
|
||||
var created = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/escalation-policies/{created.PolicyId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicyAsync(
|
||||
string policyId,
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
var existing = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Policy '{policyId}' not found." });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId) with
|
||||
{
|
||||
PolicyId = policyId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await policyService.DeletePolicyAsync(tenantId, policyId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Policy '{policyId}' not found." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Schedule Endpoints
|
||||
|
||||
private static async Task<IResult> ListSchedulesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var schedules = await scheduleService.ListSchedulesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(schedules);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var schedule = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
return schedule is null
|
||||
? Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." })
|
||||
: Results.Ok(schedule);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateScheduleAsync(
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Schedule name is required." });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId);
|
||||
var created = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/oncall-schedules/{created.ScheduleId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
var existing = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId) with
|
||||
{
|
||||
ScheduleId = scheduleId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCurrentOnCallAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromQuery] DateTimeOffset? atTime,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var users = await scheduleService.GetCurrentOnCallAsync(tenantId, scheduleId, atTime, cancellationToken);
|
||||
return Results.Ok(users);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var @override = new OnCallOverride
|
||||
{
|
||||
OverrideId = request.OverrideId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
User = new OnCallUser
|
||||
{
|
||||
UserId = request.UserId ?? "",
|
||||
Name = request.UserName ?? request.UserId ?? ""
|
||||
},
|
||||
StartsAt = request.StartsAt ?? DateTimeOffset.UtcNow,
|
||||
EndsAt = request.EndsAt ?? DateTimeOffset.UtcNow.AddHours(8),
|
||||
Reason = request.Reason
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await scheduleService.CreateOverrideAsync(tenantId, scheduleId, @override, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/oncall-schedules/{scheduleId}/overrides/{created.OverrideId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOverrideAsync(
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = "Override not found." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escalation Endpoints
|
||||
|
||||
private static async Task<IResult> ListActiveEscalationsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var escalations = await escalationEngine.ListActiveEscalationsAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(escalations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEscalationStateAsync(
|
||||
string incidentId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.GetEscalationStateAsync(tenantId, incidentId, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = $"No escalation found for incident '{incidentId}'." })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StartEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Policy ID is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await escalationEngine.StartEscalationAsync(tenantId, incidentId, request.PolicyId, cancellationToken);
|
||||
return Results.Created($"/api/v2/escalations/{incidentId}", state);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ManualEscalateAsync(
|
||||
string incidentId,
|
||||
[FromBody] ManualEscalateApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.EscalateAsync(tenantId, incidentId, request?.Reason, actor, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StopEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var stopped = await escalationEngine.StopEscalationAsync(
|
||||
tenantId, incidentId, request.Reason ?? "Manually stopped", actor, cancellationToken);
|
||||
|
||||
return stopped
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ack Endpoints
|
||||
|
||||
private static async Task<IResult> ProcessAckAsync(
|
||||
[FromBody] AckApiRequest request,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
TenantId = request.TenantId,
|
||||
IncidentId = request.IncidentId,
|
||||
AcknowledgedBy = request.AcknowledgedBy ?? "api",
|
||||
Comment = request.Comment
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(result)
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessAckLinkAsync(
|
||||
[FromQuery] string token,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ackBridge.ValidateTokenAsync(token, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new { error = validation.Error });
|
||||
}
|
||||
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = token,
|
||||
AcknowledgedBy = validation.TargetId ?? "link"
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(new { message = "Acknowledged successfully", incidentId = result.IncidentId })
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessPagerDutyWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pagerDutyAdapter = adapters.OfType<PagerDutyAdapter>().FirstOrDefault();
|
||||
if (pagerDutyAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "PagerDuty integration not configured." });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = pagerDutyAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessOpsGenieWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var opsGenieAdapter = adapters.OfType<OpsGenieAdapter>().FirstOrDefault();
|
||||
if (opsGenieAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "OpsGenie integration not configured." });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = opsGenieAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static EscalationPolicy MapToPolicy(EscalationPolicyApiRequest request, string tenantId) => new()
|
||||
{
|
||||
PolicyId = request.PolicyId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
IsDefault = request.IsDefault ?? false,
|
||||
Enabled = request.Enabled ?? true,
|
||||
EventKinds = request.EventKinds,
|
||||
MinSeverity = request.MinSeverity,
|
||||
Levels = request.Levels!.Select((l, i) => new EscalationLevel
|
||||
{
|
||||
Level = l.Level ?? i + 1,
|
||||
Name = l.Name,
|
||||
EscalateAfter = TimeSpan.FromMinutes(l.EscalateAfterMinutes ?? 15),
|
||||
Targets = l.Targets?.Select(t => new EscalationTarget
|
||||
{
|
||||
Type = Enum.TryParse<EscalationTargetType>(t.Type, true, out var type) ? type : EscalationTargetType.User,
|
||||
TargetId = t.TargetId ?? "",
|
||||
Name = t.Name,
|
||||
ChannelId = t.ChannelId
|
||||
}).ToList() ?? [],
|
||||
NotifyMode = Enum.TryParse<EscalationNotifyMode>(l.NotifyMode, true, out var mode) ? mode : EscalationNotifyMode.All,
|
||||
StopOnAck = l.StopOnAck ?? true
|
||||
}).ToList(),
|
||||
ExhaustedAction = Enum.TryParse<EscalationExhaustedAction>(request.ExhaustedAction, true, out var action)
|
||||
? action : EscalationExhaustedAction.RepeatLastLevel,
|
||||
MaxCycles = request.MaxCycles ?? 3
|
||||
};
|
||||
|
||||
private static OnCallSchedule MapToSchedule(OnCallScheduleApiRequest request, string tenantId) => new()
|
||||
{
|
||||
ScheduleId = request.ScheduleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
Timezone = request.Timezone ?? "UTC",
|
||||
Enabled = request.Enabled ?? true,
|
||||
Layers = request.Layers?.Select(l => new RotationLayer
|
||||
{
|
||||
Name = l.Name ?? "Default",
|
||||
Priority = l.Priority ?? 100,
|
||||
Users = l.Users?.Select((u, i) => new OnCallUser
|
||||
{
|
||||
UserId = u.UserId ?? "",
|
||||
Name = u.Name ?? u.UserId ?? "",
|
||||
Email = u.Email,
|
||||
Phone = u.Phone,
|
||||
PreferredChannelId = u.PreferredChannelId,
|
||||
Order = u.Order ?? i
|
||||
}).ToList() ?? [],
|
||||
Type = Enum.TryParse<RotationType>(l.RotationType, true, out var type) ? type : RotationType.Weekly,
|
||||
HandoffTime = TimeOnly.TryParse(l.HandoffTime, out var time) ? time : new TimeOnly(9, 0),
|
||||
RotationInterval = TimeSpan.FromDays(l.RotationIntervalDays ?? 7),
|
||||
RotationStart = l.RotationStart ?? DateTimeOffset.UtcNow,
|
||||
Restrictions = l.Restrictions?.Select(r => new ScheduleRestriction
|
||||
{
|
||||
Type = Enum.TryParse<RestrictionType>(r.Type, true, out var rType) ? rType : RestrictionType.DaysOfWeek,
|
||||
DaysOfWeek = r.DaysOfWeek,
|
||||
StartTime = TimeOnly.TryParse(r.StartTime, out var start) ? start : null,
|
||||
EndTime = TimeOnly.TryParse(r.EndTime, out var end) ? end : null
|
||||
}).ToList(),
|
||||
Enabled = l.Enabled ?? true
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region API Request Models
|
||||
|
||||
public sealed class EscalationPolicyApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? IsDefault { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<EscalationLevelApiRequest>? Levels { get; set; }
|
||||
public string? ExhaustedAction { get; set; }
|
||||
public int? MaxCycles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationLevelApiRequest
|
||||
{
|
||||
public int? Level { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int? EscalateAfterMinutes { get; set; }
|
||||
public List<EscalationTargetApiRequest>? Targets { get; set; }
|
||||
public string? NotifyMode { get; set; }
|
||||
public bool? StopOnAck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationTargetApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? TargetId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? ChannelId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallScheduleApiRequest
|
||||
{
|
||||
public string? ScheduleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<RotationLayerApiRequest>? Layers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RotationLayerApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<OnCallUserApiRequest>? Users { get; set; }
|
||||
public string? RotationType { get; set; }
|
||||
public string? HandoffTime { get; set; }
|
||||
public int? RotationIntervalDays { get; set; }
|
||||
public DateTimeOffset? RotationStart { get; set; }
|
||||
public List<ScheduleRestrictionApiRequest>? Restrictions { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallUserApiRequest
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredChannelId { get; set; }
|
||||
public int? Order { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScheduleRestrictionApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallOverrideApiRequest
|
||||
{
|
||||
public string? OverrideId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StartEscalationApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManualEscalateApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StopEscalationApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AckApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? IncidentId { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for fallback handler operations.
|
||||
/// </summary>
|
||||
public static class FallbackEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps fallback API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/fallback")
|
||||
.WithTags("Fallback")
|
||||
.WithOpenApi();
|
||||
|
||||
// Get fallback statistics
|
||||
group.MapGet("/statistics", async (
|
||||
int? windowHours,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
|
||||
|
||||
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stats.TenantId,
|
||||
window = stats.Window.ToString(),
|
||||
stats.TotalDeliveries,
|
||||
stats.PrimarySuccesses,
|
||||
stats.FallbackAttempts,
|
||||
stats.FallbackSuccesses,
|
||||
stats.ExhaustedDeliveries,
|
||||
successRate = $"{stats.SuccessRate:P1}",
|
||||
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
|
||||
failuresByChannel = stats.FailuresByChannel.ToDictionary(
|
||||
kvp => kvp.Key.ToString(),
|
||||
kvp => kvp.Value)
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackStatistics")
|
||||
.WithSummary("Gets fallback handling statistics for a tenant");
|
||||
|
||||
// Get fallback chain for a channel
|
||||
group.MapGet("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList(),
|
||||
chainLength = chain.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackChain")
|
||||
.WithSummary("Gets the fallback chain for a channel type");
|
||||
|
||||
// Set fallback chain for a channel
|
||||
group.MapPut("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
SetFallbackChainRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var chain = request.FallbackChain
|
||||
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
|
||||
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
message = "Fallback chain updated successfully",
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("SetFallbackChain")
|
||||
.WithSummary("Sets a custom fallback chain for a channel type");
|
||||
|
||||
// Test fallback resolution
|
||||
group.MapPost("/test", async (
|
||||
TestFallbackRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
|
||||
}
|
||||
|
||||
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Simulate failure recording
|
||||
await fallbackHandler.RecordFailureAsync(
|
||||
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
|
||||
|
||||
// Get fallback result
|
||||
var result = await fallbackHandler.GetFallbackAsync(
|
||||
tenantId, channelType, deliveryId, cancellationToken);
|
||||
|
||||
// Clean up test state
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
testDeliveryId = deliveryId,
|
||||
result.HasFallback,
|
||||
nextChannelType = result.NextChannelType?.ToString(),
|
||||
result.AttemptNumber,
|
||||
result.TotalChannels,
|
||||
result.IsExhausted,
|
||||
result.ExhaustionReason,
|
||||
failedChannels = result.FailedChannels.Select(f => new
|
||||
{
|
||||
channelType = f.ChannelType.ToString(),
|
||||
f.Reason,
|
||||
f.FailedAt,
|
||||
f.AttemptNumber
|
||||
}).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("TestFallback")
|
||||
.WithSummary("Tests fallback resolution without affecting real deliveries");
|
||||
|
||||
// Clear delivery state
|
||||
group.MapDelete("/deliveries/{deliveryId}", async (
|
||||
string deliveryId,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
|
||||
})
|
||||
.WithName("ClearDeliveryFallbackState")
|
||||
.WithSummary("Clears fallback state for a specific delivery");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a custom fallback chain.
|
||||
/// </summary>
|
||||
public sealed record SetFallbackChainRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered list of fallback channel types.
|
||||
/// </summary>
|
||||
public required List<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test fallback resolution.
|
||||
/// </summary>
|
||||
public sealed record TestFallbackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type that "failed".
|
||||
/// </summary>
|
||||
public required string FailedChannelType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps incident (delivery) management endpoints.
|
||||
/// </summary>
|
||||
public static class IncidentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/incidents")
|
||||
.WithTags("Incidents");
|
||||
|
||||
group.MapGet("/", ListIncidentsAsync)
|
||||
.WithName("ListIncidents")
|
||||
.WithSummary("Lists notification incidents (deliveries)");
|
||||
|
||||
group.MapGet("/{deliveryId}", GetIncidentAsync)
|
||||
.WithName("GetIncident")
|
||||
.WithSummary("Gets an incident by delivery ID");
|
||||
|
||||
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
|
||||
.WithName("AcknowledgeIncident")
|
||||
.WithSummary("Acknowledges an incident");
|
||||
|
||||
group.MapGet("/stats", GetIncidentStatsAsync)
|
||||
.WithName("GetIncidentStats")
|
||||
.WithSummary("Gets incident statistics");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListIncidentsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
string? status = null,
|
||||
string? kind = null,
|
||||
string? ruleId = null,
|
||||
int? limit = null,
|
||||
string? continuationToken = null,
|
||||
DateTimeOffset? since = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
// Query deliveries with filtering
|
||||
var queryResult = await deliveries.QueryAsync(
|
||||
tenantId,
|
||||
since,
|
||||
status,
|
||||
limit ?? 50,
|
||||
continuationToken,
|
||||
context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyDelivery> filtered = queryResult.Items;
|
||||
|
||||
// Apply additional filters not supported by the repository
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToDeliveryResponse).ToList();
|
||||
|
||||
// Add continuation token header for pagination
|
||||
if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
|
||||
{
|
||||
context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDeliveryResponse(delivery));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcknowledgeIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
DeliveryAckRequest request,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
|
||||
}
|
||||
|
||||
// Update delivery status based on acknowledgment
|
||||
var newStatus = request.Resolution?.ToLowerInvariant() switch
|
||||
{
|
||||
"resolved" => NotifyDeliveryStatus.Delivered,
|
||||
"dismissed" => NotifyDeliveryStatus.Failed,
|
||||
_ => delivery.Status
|
||||
};
|
||||
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: timeProvider.GetUtcNow(),
|
||||
status: NotifyDeliveryAttemptStatus.Success,
|
||||
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
|
||||
|
||||
var updated = delivery with
|
||||
{
|
||||
Status = newStatus,
|
||||
StatusReason = request.Comment ?? $"Acknowledged: {request.Resolution}",
|
||||
CompletedAt = timeProvider.GetUtcNow(),
|
||||
Attempts = delivery.Attempts.Add(attempt)
|
||||
};
|
||||
|
||||
await deliveries.UpdateAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
|
||||
{
|
||||
deliveryId,
|
||||
request.Resolution,
|
||||
request.Comment
|
||||
}, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentStatsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
var stats = new DeliveryStatsResponse
|
||||
{
|
||||
Total = allDeliveries.Count,
|
||||
Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
|
||||
Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
|
||||
Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
|
||||
ByKind = allDeliveries
|
||||
.GroupBy(d => d.Kind)
|
||||
.ToDictionary(g => g.Key, g => g.Count()),
|
||||
ByRule = allDeliveries
|
||||
.GroupBy(d => d.RuleId)
|
||||
.ToDictionary(g => g.Key, g => g.Count())
|
||||
};
|
||||
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
|
||||
{
|
||||
return new DeliveryResponse
|
||||
{
|
||||
DeliveryId = delivery.DeliveryId,
|
||||
TenantId = delivery.TenantId,
|
||||
RuleId = delivery.RuleId,
|
||||
ActionId = delivery.ActionId,
|
||||
EventId = delivery.EventId.ToString(),
|
||||
Kind = delivery.Kind,
|
||||
Status = delivery.Status.ToString(),
|
||||
StatusReason = delivery.StatusReason,
|
||||
AttemptCount = delivery.Attempts.Length,
|
||||
LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
|
||||
CreatedAt = delivery.CreatedAt,
|
||||
SentAt = delivery.SentAt,
|
||||
CompletedAt = delivery.CompletedAt,
|
||||
Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery acknowledgment request for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAckRequest
|
||||
{
|
||||
public string? Resolution { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery response DTO for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string ActionId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? StatusReason { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttempt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int Total { get; init; }
|
||||
public required int Pending { get; init; }
|
||||
public required int Delivered { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required Dictionary<string, int> ByKind { get; init; }
|
||||
public required Dictionary<string, int> ByRule { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket live feed for real-time incident updates.
|
||||
/// </summary>
|
||||
public static class IncidentLiveFeed
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _tenantSubscriptions = new();
|
||||
|
||||
public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task HandleWebSocketAsync(HttpContext context)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "websocket_required",
|
||||
message = "This endpoint requires a WebSocket connection.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
// Try query string fallback for WebSocket clients that can't set headers
|
||||
tenantId = context.Request.Query["tenant"].ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "tenant_missing",
|
||||
message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
|
||||
var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag<WebSocket>());
|
||||
subscriptions.Add(webSocket);
|
||||
|
||||
try
|
||||
{
|
||||
// Send connection acknowledgment
|
||||
var ackMessage = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "connected",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
|
||||
|
||||
// Keep connection alive and handle incoming messages
|
||||
await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove from subscriptions
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"Client initiated close",
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("type", out var typeElement))
|
||||
{
|
||||
var type = typeElement.GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "ping":
|
||||
var pongResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "pong",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, pongResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
case "subscribe":
|
||||
// Handle filter subscriptions (e.g., specific rule IDs, kinds)
|
||||
var subResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "subscribed",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, subResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = $"Unknown message type: {type}"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = "Invalid JSON message"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (webSocket.State != WebSocketState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
await webSocket.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an incident update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastIncidentUpdateAsync(
|
||||
string tenantId,
|
||||
NotifyDelivery delivery,
|
||||
string updateType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "incident_update",
|
||||
updateType, // created, updated, acknowledged, delivered, failed
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
incident = new
|
||||
{
|
||||
deliveryId = delivery.DeliveryId,
|
||||
tenantId = delivery.TenantId,
|
||||
ruleId = delivery.RuleId,
|
||||
actionId = delivery.ActionId,
|
||||
eventId = delivery.EventId.ToString(),
|
||||
kind = delivery.Kind,
|
||||
status = delivery.Status.ToString(),
|
||||
statusReason = delivery.StatusReason,
|
||||
attemptCount = delivery.Attempts.Length,
|
||||
createdAt = delivery.CreatedAt,
|
||||
sentAt = delivery.SentAt,
|
||||
completedAt = delivery.CompletedAt
|
||||
}
|
||||
});
|
||||
|
||||
var deadSockets = new List<WebSocket>();
|
||||
|
||||
foreach (var socket in subscriptions)
|
||||
{
|
||||
if (socket.State != WebSocketState.Open)
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead sockets
|
||||
if (deadSockets.Count > 0)
|
||||
{
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => !deadSockets.Contains(s)));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastStatsUpdateAsync(
|
||||
string tenantId,
|
||||
int total,
|
||||
int pending,
|
||||
int delivered,
|
||||
int failed,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "stats_update",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
stats = new
|
||||
{
|
||||
total,
|
||||
pending,
|
||||
delivered,
|
||||
failed
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore send failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of active WebSocket connections for a tenant.
|
||||
/// </summary>
|
||||
public static int GetConnectionCount(string tenantId)
|
||||
{
|
||||
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return subscriptions.Count(s => s.State == WebSocketState.Open);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for localization operations.
|
||||
/// </summary>
|
||||
public static class LocalizationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps localization API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/localization")
|
||||
.WithTags("Localization")
|
||||
.WithOpenApi();
|
||||
|
||||
// List bundles
|
||||
group.MapGet("/bundles", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
bundles = bundles.Select(b => new
|
||||
{
|
||||
b.BundleId,
|
||||
b.TenantId,
|
||||
b.Locale,
|
||||
b.Namespace,
|
||||
stringCount = b.Strings.Count,
|
||||
b.Priority,
|
||||
b.Enabled,
|
||||
b.Source,
|
||||
b.Description,
|
||||
b.CreatedAt,
|
||||
b.UpdatedAt
|
||||
}).ToList(),
|
||||
count = bundles.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListLocalizationBundles")
|
||||
.WithSummary("Lists all localization bundles for a tenant");
|
||||
|
||||
// Get supported locales
|
||||
group.MapGet("/locales", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locales,
|
||||
count = locales.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetSupportedLocales")
|
||||
.WithSummary("Gets all supported locales for a tenant");
|
||||
|
||||
// Get bundle contents
|
||||
group.MapGet("/bundles/{locale}", async (
|
||||
string locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locale,
|
||||
strings,
|
||||
count = strings.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizationBundle")
|
||||
.WithSummary("Gets all localized strings for a locale");
|
||||
|
||||
// Get single string
|
||||
group.MapGet("/strings/{key}", async (
|
||||
string key,
|
||||
string? locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
|
||||
var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale = effectiveLocale,
|
||||
value
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizedString")
|
||||
.WithSummary("Gets a single localized string");
|
||||
|
||||
// Format string with parameters
|
||||
group.MapPost("/strings/{key}/format", async (
|
||||
string key,
|
||||
FormatStringRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var locale = request.Locale ?? "en-US";
|
||||
|
||||
var parameters = request.Parameters ?? new Dictionary<string, object>();
|
||||
var value = await localizationService.GetFormattedStringAsync(
|
||||
tenantId, key, locale, parameters, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale,
|
||||
formatted = value
|
||||
});
|
||||
})
|
||||
.WithName("FormatLocalizedString")
|
||||
.WithSummary("Gets a localized string with parameter substitution");
|
||||
|
||||
// Create/update bundle
|
||||
group.MapPut("/bundles", async (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description,
|
||||
Source = "api"
|
||||
};
|
||||
|
||||
var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = "Bundle created successfully"
|
||||
})
|
||||
: Results.Ok(new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = "Bundle updated successfully"
|
||||
});
|
||||
})
|
||||
.WithName("UpsertLocalizationBundle")
|
||||
.WithSummary("Creates or updates a localization bundle");
|
||||
|
||||
// Delete bundle
|
||||
group.MapDelete("/bundles/{bundleId}", async (
|
||||
string bundleId,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Bundle '{bundleId}' not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
|
||||
})
|
||||
.WithName("DeleteLocalizationBundle")
|
||||
.WithSummary("Deletes a localization bundle");
|
||||
|
||||
// Validate bundle
|
||||
group.MapPost("/bundles/validate", (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? "validation",
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
var result = localizationService.Validate(bundle);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.IsValid,
|
||||
result.Errors,
|
||||
result.Warnings
|
||||
});
|
||||
})
|
||||
.WithName("ValidateLocalizationBundle")
|
||||
.WithSummary("Validates a localization bundle without saving");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to format a localized string.
|
||||
/// </summary>
|
||||
public sealed record FormatStringRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Target locale.
|
||||
/// </summary>
|
||||
public string? Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for substitution.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Parameters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record CreateBundleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle ID (auto-generated if not provided).
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code.
|
||||
/// </summary>
|
||||
public required string Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Namespace/category.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Localized strings.
|
||||
/// </summary>
|
||||
public required Dictionary<string, string> Strings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle priority.
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether bundle is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rules, templates, and incidents management.
|
||||
/// </summary>
|
||||
public static class NotifyApiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all Notify API v2 endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/notify")
|
||||
.WithTags("Notify")
|
||||
.WithOpenApi();
|
||||
|
||||
// Rules CRUD
|
||||
MapRulesEndpoints(group);
|
||||
|
||||
// Templates CRUD + Preview
|
||||
MapTemplatesEndpoints(group);
|
||||
|
||||
// Incidents
|
||||
MapIncidentsEndpoints(group);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapRulesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/rules", async (
|
||||
HttpContext context,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
|
||||
var response = rules.Select(MapRuleToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
group.MapGet("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapRuleToResponse(rule));
|
||||
});
|
||||
|
||||
group.MapPost("/rules", async (
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var rule = MapRequestToRule(request, tenantId, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(rule, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = rule.RuleId,
|
||||
["name"] = rule.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
|
||||
});
|
||||
|
||||
group.MapPut("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var updated = ApplyRuleUpdate(existing, request, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(updated, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = updated.RuleId,
|
||||
["name"] = updated.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Ok(MapRuleToResponse(updated));
|
||||
});
|
||||
|
||||
group.MapDelete("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = ruleId
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapTemplatesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/templates", async (
|
||||
HttpContext context,
|
||||
string? keyPrefix,
|
||||
string? channelType,
|
||||
string? locale,
|
||||
int? limit,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyChannelType? channelTypeEnum = null;
|
||||
if (!string.IsNullOrWhiteSpace(channelType) &&
|
||||
Enum.TryParse<NotifyChannelType>(channelType, true, out var parsed))
|
||||
{
|
||||
channelTypeEnum = parsed;
|
||||
}
|
||||
|
||||
var templates = await templateService.ListAsync(tenantId, new TemplateListOptions
|
||||
{
|
||||
KeyPrefix = keyPrefix,
|
||||
ChannelType = channelTypeEnum,
|
||||
Locale = locale,
|
||||
Limit = limit
|
||||
}, cancellationToken);
|
||||
|
||||
var response = templates.Select(MapTemplateToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
group.MapGet("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapTemplateToResponse(template));
|
||||
});
|
||||
|
||||
group.MapPost("/templates", async (
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_channel_type", $"Invalid channel type: {request.ChannelType}", context));
|
||||
}
|
||||
|
||||
var renderMode = NotifyTemplateRenderMode.Markdown;
|
||||
if (!string.IsNullOrWhiteSpace(request.RenderMode) &&
|
||||
Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var parsedMode))
|
||||
{
|
||||
renderMode = parsedMode;
|
||||
}
|
||||
|
||||
var format = NotifyDeliveryFormat.Json;
|
||||
if (!string.IsNullOrWhiteSpace(request.Format) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata);
|
||||
|
||||
var result = await templateService.UpsertAsync(template, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(Error("template_validation_failed", result.Error ?? "Validation failed.", context));
|
||||
}
|
||||
|
||||
var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
|
||||
: Results.Ok(MapTemplateToResponse(created!));
|
||||
});
|
||||
|
||||
group.MapDelete("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapPost("/templates/preview", async (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
INotifyTemplateRenderer templateRenderer,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
List<string>? warnings = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {request.TemplateId} not found.", context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
var validation = templateService.Validate(request.TemplateBody);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
warnings = validation.Warnings.ToList();
|
||||
|
||||
var format = NotifyDeliveryFormat.PlainText;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputFormat) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("template_required", "Either templateId or templateBody must be provided.", context));
|
||||
}
|
||||
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "preview.event",
|
||||
tenant: tenantId,
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/templates/validate", (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
return Results.BadRequest(Error("template_body_required", "templateBody is required.", context));
|
||||
}
|
||||
|
||||
var result = templateService.Validate(request.TemplateBody);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
isValid = result.IsValid,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/incidents", async (
|
||||
HttpContext context,
|
||||
string? status,
|
||||
string? eventKindPrefix,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? until,
|
||||
int? limit,
|
||||
string? cursor,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
// For now, return recent deliveries grouped by event kind as "incidents"
|
||||
// Full incident correlation will be implemented in NOTIFY-SVC-39-001
|
||||
var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken);
|
||||
var deliveries = queryResult.Items;
|
||||
|
||||
var incidents = deliveries
|
||||
.GroupBy(d => d.EventId)
|
||||
.Select(g => new IncidentResponse
|
||||
{
|
||||
IncidentId = g.Key.ToString(),
|
||||
TenantId = tenantId,
|
||||
EventKind = g.First().Kind,
|
||||
Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open",
|
||||
Severity = "medium",
|
||||
Title = $"Notification: {g.First().Kind}",
|
||||
Description = null,
|
||||
EventCount = g.Count(),
|
||||
FirstOccurrence = g.Min(d => d.CreatedAt),
|
||||
LastOccurrence = g.Max(d => d.CreatedAt),
|
||||
Labels = null,
|
||||
Metadata = null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new IncidentListResponse
|
||||
{
|
||||
Incidents = incidents,
|
||||
TotalCount = incidents.Count,
|
||||
NextCursor = queryResult.ContinuationToken
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/ack", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentAckRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/resolve", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentResolveRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["reason"] = request.Reason ?? "",
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var value = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor
|
||||
? actor
|
||||
: "api";
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task AuditAsync(
|
||||
INotifyAuditRepository repository,
|
||||
string tenantId,
|
||||
string action,
|
||||
string actor,
|
||||
Dictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mappers
|
||||
|
||||
private static RuleResponse MapRuleToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyRule MapRequestToRule(
|
||||
RuleCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule ApplyRuleUpdate(
|
||||
NotifyRule existing,
|
||||
RuleUpdateRequest request,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(),
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(),
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(),
|
||||
digests: request.Match.Digests ?? existing.Match.Digests.ToList(),
|
||||
labels: request.Match.Labels ?? existing.Match.Labels.ToList(),
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(),
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(),
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapTemplateToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for observability services.
|
||||
/// </summary>
|
||||
public static class ObservabilityEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps observability endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/observability")
|
||||
.WithTags("Observability");
|
||||
|
||||
// Metrics endpoints
|
||||
group.MapGet("/metrics", GetMetricsSnapshot)
|
||||
.WithName("GetMetricsSnapshot")
|
||||
.WithSummary("Gets current metrics snapshot");
|
||||
|
||||
group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
|
||||
.WithName("GetTenantMetrics")
|
||||
.WithSummary("Gets metrics for a specific tenant");
|
||||
|
||||
// Dead letter endpoints
|
||||
group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
|
||||
.WithName("GetDeadLetters")
|
||||
.WithSummary("Lists dead letter entries for a tenant");
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
|
||||
.WithName("GetDeadLetterEntry")
|
||||
.WithSummary("Gets a specific dead letter entry");
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
|
||||
.WithName("RetryDeadLetter")
|
||||
.WithSummary("Retries a dead letter entry");
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
|
||||
.WithName("DiscardDeadLetter")
|
||||
.WithSummary("Discards a dead letter entry");
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
|
||||
.WithName("GetDeadLetterStats")
|
||||
.WithSummary("Gets dead letter statistics");
|
||||
|
||||
group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
|
||||
.WithName("PurgeDeadLetters")
|
||||
.WithSummary("Purges old dead letter entries");
|
||||
|
||||
// Chaos testing endpoints
|
||||
group.MapGet("/chaos/experiments", ListChaosExperiments)
|
||||
.WithName("ListChaosExperiments")
|
||||
.WithSummary("Lists chaos experiments");
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
|
||||
.WithName("GetChaosExperiment")
|
||||
.WithSummary("Gets a chaos experiment");
|
||||
|
||||
group.MapPost("/chaos/experiments", StartChaosExperiment)
|
||||
.WithName("StartChaosExperiment")
|
||||
.WithSummary("Starts a new chaos experiment");
|
||||
|
||||
group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
|
||||
.WithName("StopChaosExperiment")
|
||||
.WithSummary("Stops a running chaos experiment");
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
|
||||
.WithName("GetChaosResults")
|
||||
.WithSummary("Gets chaos experiment results");
|
||||
|
||||
// Retention policy endpoints
|
||||
group.MapGet("/retention/policies", ListRetentionPolicies)
|
||||
.WithName("ListRetentionPolicies")
|
||||
.WithSummary("Lists retention policies");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
|
||||
.WithName("GetRetentionPolicy")
|
||||
.WithSummary("Gets a retention policy");
|
||||
|
||||
group.MapPost("/retention/policies", CreateRetentionPolicy)
|
||||
.WithName("CreateRetentionPolicy")
|
||||
.WithSummary("Creates a retention policy");
|
||||
|
||||
group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
|
||||
.WithName("UpdateRetentionPolicy")
|
||||
.WithSummary("Updates a retention policy");
|
||||
|
||||
group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
|
||||
.WithName("DeleteRetentionPolicy")
|
||||
.WithSummary("Deletes a retention policy");
|
||||
|
||||
group.MapPost("/retention/execute", ExecuteRetention)
|
||||
.WithName("ExecuteRetention")
|
||||
.WithSummary("Executes retention policies");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
|
||||
.WithName("PreviewRetention")
|
||||
.WithSummary("Previews retention policy effects");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
|
||||
.WithName("GetRetentionHistory")
|
||||
.WithSummary("Gets retention execution history");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Metrics handlers
|
||||
private static IResult GetMetricsSnapshot(
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
private static IResult GetTenantMetrics(
|
||||
string tenantId,
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot(tenantId);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// Dead letter handlers
|
||||
private static async Task<IResult> GetDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = await handler.GetEntriesAsync(
|
||||
tenantId,
|
||||
limit: limit > 0 ? limit : 100,
|
||||
offset: offset,
|
||||
ct: ct);
|
||||
return Results.Ok(entries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterEntry(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entry = await handler.GetEntryAsync(tenantId, entryId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Dead letter entry not found" });
|
||||
}
|
||||
return Results.Ok(entry);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] RetryDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await handler.RetryAsync(tenantId, entryId, request.Actor, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DiscardDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] DiscardDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await handler.DiscardAsync(tenantId, entryId, request.Reason, request.Actor, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterStats(
|
||||
string tenantId,
|
||||
[FromQuery] int? windowHours,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var window = windowHours.HasValue
|
||||
? TimeSpan.FromHours(windowHours.Value)
|
||||
: (TimeSpan?)null;
|
||||
var stats = await handler.GetStatisticsAsync(tenantId, window, ct);
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int olderThanDays,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
|
||||
var count = await handler.PurgeAsync(tenantId, olderThan, ct);
|
||||
return Results.Ok(new { purged = count });
|
||||
}
|
||||
|
||||
// Chaos testing handlers
|
||||
private static async Task<IResult> ListChaosExperiments(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ChaosExperimentStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<ChaosExperimentStatus>(status, true, out var s))
|
||||
{
|
||||
parsedStatus = s;
|
||||
}
|
||||
|
||||
var experiments = await runner.ListExperimentsAsync(parsedStatus, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(experiments);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var experiment = await runner.GetExperimentAsync(experimentId, ct);
|
||||
if (experiment is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Experiment not found" });
|
||||
}
|
||||
return Results.Ok(experiment);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartChaosExperiment(
|
||||
[FromBody] ChaosExperimentConfig config,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var experiment = await runner.StartExperimentAsync(config, ct);
|
||||
return Results.Created($"/api/v1/observability/chaos/experiments/{experiment.Id}", experiment);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await runner.StopExperimentAsync(experimentId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosResults(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = await runner.GetResultsAsync(experimentId, ct);
|
||||
return Results.Ok(results);
|
||||
}
|
||||
|
||||
// Retention policy handlers
|
||||
private static async Task<IResult> ListRetentionPolicies(
|
||||
[FromQuery] string? tenantId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policies = await service.ListPoliciesAsync(tenantId, ct);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetPolicyAsync(policyId, ct);
|
||||
if (policy is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRetentionPolicy(
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.RegisterPolicyAsync(policy, ct);
|
||||
return Results.Created($"/api/v1/observability/retention/policies/{policy.Id}", policy);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRetentionPolicy(
|
||||
string policyId,
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.UpdatePolicyAsync(policyId, policy, ct);
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await service.DeletePolicyAsync(policyId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExecuteRetention(
|
||||
[FromQuery] string? policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await service.ExecuteRetentionAsync(policyId, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRetention(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var preview = await service.PreviewRetentionAsync(policyId, ct);
|
||||
return Results.Ok(preview);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionHistory(
|
||||
string policyId,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var history = await service.GetExecutionHistoryAsync(policyId, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(history);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the retry.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to discard a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record DiscardDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for discarding.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor performing the discard.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for operator override management.
|
||||
/// </summary>
|
||||
public static class OperatorOverrideEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps operator override endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/overrides")
|
||||
.WithTags("Overrides")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/", ListOverridesAsync)
|
||||
.WithName("ListOperatorOverrides")
|
||||
.WithSummary("List active operator overrides")
|
||||
.WithDescription("Returns all active operator overrides for the tenant.");
|
||||
|
||||
group.MapGet("/{overrideId}", GetOverrideAsync)
|
||||
.WithName("GetOperatorOverride")
|
||||
.WithSummary("Get an operator override")
|
||||
.WithDescription("Returns a specific operator override by ID.");
|
||||
|
||||
group.MapPost("/", CreateOverrideAsync)
|
||||
.WithName("CreateOperatorOverride")
|
||||
.WithSummary("Create an operator override")
|
||||
.WithDescription("Creates a new operator override to bypass quiet hours and/or throttling.");
|
||||
|
||||
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
|
||||
.WithName("RevokeOperatorOverride")
|
||||
.WithSummary("Revoke an operator override")
|
||||
.WithDescription("Revokes an active operator override.");
|
||||
|
||||
group.MapPost("/check", CheckOverrideAsync)
|
||||
.WithName("CheckOperatorOverride")
|
||||
.WithSummary("Check for applicable override")
|
||||
.WithDescription("Checks if an override applies to the given event criteria.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListOverridesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(overrides.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOverrideAsync(
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
|
||||
|
||||
if (@override is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Override '{overrideId}' not found." });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(@override));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
[FromBody] OperatorOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Reason is required." });
|
||||
}
|
||||
|
||||
if (request.DurationMinutes is null or <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Duration must be a positive value in minutes." });
|
||||
}
|
||||
|
||||
var createRequest = new OperatorOverrideCreate
|
||||
{
|
||||
Type = MapOverrideType(request.Type),
|
||||
Reason = request.Reason,
|
||||
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
|
||||
EffectiveFrom = request.EffectiveFrom,
|
||||
EventKinds = request.EventKinds,
|
||||
CorrelationKeys = request.CorrelationKeys,
|
||||
MaxUsageCount = request.MaxUsageCount
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeOverrideAsync(
|
||||
string overrideId,
|
||||
[FromBody] RevokeOverrideApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var actor = request?.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
|
||||
}
|
||||
|
||||
var revoked = await overrideService.RevokeOverrideAsync(
|
||||
tenantId,
|
||||
overrideId,
|
||||
actor,
|
||||
request?.Reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Override '{overrideId}' not found or already inactive." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CheckOverrideAsync(
|
||||
[FromBody] CheckOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var result = await overrideService.CheckOverrideAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.CorrelationKey,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new CheckOverrideApiResponse
|
||||
{
|
||||
HasOverride = result.HasOverride,
|
||||
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
|
||||
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
|
||||
});
|
||||
}
|
||||
|
||||
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
|
||||
"throttle" => OverrideType.Throttle,
|
||||
"maintenance" => OverrideType.Maintenance,
|
||||
"all" or _ => OverrideType.All
|
||||
};
|
||||
|
||||
private static List<string> MapOverrideTypeToStrings(OverrideType type)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
|
||||
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
|
||||
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
|
||||
{
|
||||
OverrideId = @override.OverrideId,
|
||||
TenantId = @override.TenantId,
|
||||
Type = MapOverrideTypeToStrings(@override.Type),
|
||||
Reason = @override.Reason,
|
||||
EffectiveFrom = @override.EffectiveFrom,
|
||||
ExpiresAt = @override.ExpiresAt,
|
||||
EventKinds = @override.EventKinds.ToList(),
|
||||
CorrelationKeys = @override.CorrelationKeys.ToList(),
|
||||
MaxUsageCount = @override.MaxUsageCount,
|
||||
UsageCount = @override.UsageCount,
|
||||
Status = @override.Status.ToString().ToLowerInvariant(),
|
||||
CreatedBy = @override.CreatedBy,
|
||||
CreatedAt = @override.CreatedAt,
|
||||
RevokedBy = @override.RevokedBy,
|
||||
RevokedAt = @override.RevokedAt,
|
||||
RevocationReason = @override.RevocationReason
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int? DurationMinutes { get; set; }
|
||||
public DateTimeOffset? EffectiveFrom { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an operator override.
|
||||
/// </summary>
|
||||
public sealed class RevokeOverrideApiRequest
|
||||
{
|
||||
public string? Actor { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to check for applicable override.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public string? CorrelationKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiResponse
|
||||
{
|
||||
public required string OverrideId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required List<string> Type { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required DateTimeOffset EffectiveFrom { get; set; }
|
||||
public required DateTimeOffset ExpiresAt { get; set; }
|
||||
public required List<string> EventKinds { get; set; }
|
||||
public required List<string> CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
public required int UsageCount { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for override check.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiResponse
|
||||
{
|
||||
public required bool HasOverride { get; set; }
|
||||
public required List<string> BypassedTypes { get; set; }
|
||||
public OperatorOverrideApiResponse? Override { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,351 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for quiet hours calendar management.
|
||||
/// </summary>
|
||||
public static class QuietHoursEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps quiet hours endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/quiet-hours")
|
||||
.WithTags("QuietHours")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/calendars", ListCalendarsAsync)
|
||||
.WithName("ListQuietHoursCalendars")
|
||||
.WithSummary("List all quiet hours calendars")
|
||||
.WithDescription("Returns all quiet hours calendars for the tenant.");
|
||||
|
||||
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
|
||||
.WithName("GetQuietHoursCalendar")
|
||||
.WithSummary("Get a quiet hours calendar")
|
||||
.WithDescription("Returns a specific quiet hours calendar by ID.");
|
||||
|
||||
group.MapPost("/calendars", CreateCalendarAsync)
|
||||
.WithName("CreateQuietHoursCalendar")
|
||||
.WithSummary("Create a quiet hours calendar")
|
||||
.WithDescription("Creates a new quiet hours calendar with schedules.");
|
||||
|
||||
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
|
||||
.WithName("UpdateQuietHoursCalendar")
|
||||
.WithSummary("Update a quiet hours calendar")
|
||||
.WithDescription("Updates an existing quiet hours calendar.");
|
||||
|
||||
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
|
||||
.WithName("DeleteQuietHoursCalendar")
|
||||
.WithSummary("Delete a quiet hours calendar")
|
||||
.WithDescription("Deletes a quiet hours calendar.");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateQuietHours")
|
||||
.WithSummary("Evaluate quiet hours")
|
||||
.WithDescription("Checks if quiet hours are currently active for an event kind.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCalendarsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(calendars.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
|
||||
if (calendar is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(calendar));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCalendarAsync(
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Calendar name is required." });
|
||||
}
|
||||
|
||||
if (request.Schedules is null || request.Schedules.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one schedule is required." });
|
||||
}
|
||||
|
||||
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Enabled = request.Enabled ?? true,
|
||||
Priority = request.Priority ?? 100,
|
||||
Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
|
||||
ExcludedEventKinds = request.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds
|
||||
};
|
||||
|
||||
var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCalendarAsync(
|
||||
string calendarId,
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
|
||||
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] QuietHoursEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var result = await calendarService.EvaluateAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.EvaluationTime,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new QuietHoursEvaluateApiResponse
|
||||
{
|
||||
IsActive = result.IsActive,
|
||||
MatchedCalendarId = result.MatchedCalendarId,
|
||||
MatchedCalendarName = result.MatchedCalendarName,
|
||||
MatchedScheduleName = result.MatchedScheduleName,
|
||||
EndsAt = result.EndsAt,
|
||||
Reason = result.Reason
|
||||
});
|
||||
}
|
||||
|
||||
private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
|
||||
{
|
||||
Name = request.Name ?? "Unnamed Schedule",
|
||||
StartTime = request.StartTime ?? "00:00",
|
||||
EndTime = request.EndTime ?? "00:00",
|
||||
DaysOfWeek = request.DaysOfWeek,
|
||||
Timezone = request.Timezone,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
|
||||
{
|
||||
CalendarId = calendar.CalendarId,
|
||||
TenantId = calendar.TenantId,
|
||||
Name = calendar.Name,
|
||||
Description = calendar.Description,
|
||||
Enabled = calendar.Enabled,
|
||||
Priority = calendar.Priority,
|
||||
Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
|
||||
{
|
||||
Name = s.Name,
|
||||
StartTime = s.StartTime,
|
||||
EndTime = s.EndTime,
|
||||
DaysOfWeek = s.DaysOfWeek?.ToList(),
|
||||
Timezone = s.Timezone,
|
||||
Enabled = s.Enabled
|
||||
}).ToList(),
|
||||
ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
|
||||
IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
|
||||
CreatedAt = calendar.CreatedAt,
|
||||
CreatedBy = calendar.CreatedBy,
|
||||
UpdatedAt = calendar.UpdatedAt,
|
||||
UpdatedBy = calendar.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiRequest
|
||||
{
|
||||
public string? CalendarId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<QuietHoursScheduleApiRequest>? Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar request.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate quiet hours.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public DateTimeOffset? EvaluationTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiResponse
|
||||
{
|
||||
public required string CalendarId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int Priority { get; set; }
|
||||
public required List<QuietHoursScheduleApiResponse> Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar response.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiResponse
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string StartTime { get; set; }
|
||||
public required string EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for quiet hours evaluation.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiResponse
|
||||
{
|
||||
public required bool IsActive { get; set; }
|
||||
public string? MatchedCalendarId { get; set; }
|
||||
public string? MatchedCalendarName { get; set; }
|
||||
public string? MatchedScheduleName { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps rule management endpoints.
|
||||
/// </summary>
|
||||
public static class RuleEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/rules")
|
||||
.WithTags("Rules");
|
||||
|
||||
group.MapGet("/", ListRulesAsync)
|
||||
.WithName("ListRules")
|
||||
.WithSummary("Lists all rules for a tenant");
|
||||
|
||||
group.MapGet("/{ruleId}", GetRuleAsync)
|
||||
.WithName("GetRule")
|
||||
.WithSummary("Gets a rule by ID");
|
||||
|
||||
group.MapPost("/", CreateRuleAsync)
|
||||
.WithName("CreateRule")
|
||||
.WithSummary("Creates a new rule");
|
||||
|
||||
group.MapPut("/{ruleId}", UpdateRuleAsync)
|
||||
.WithName("UpdateRule")
|
||||
.WithSummary("Updates an existing rule");
|
||||
|
||||
group.MapDelete("/{ruleId}", DeleteRuleAsync)
|
||||
.WithName("DeleteRule")
|
||||
.WithSummary("Deletes a rule");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRulesAsync(
|
||||
HttpContext context,
|
||||
INotifyRuleRepository rules,
|
||||
bool? enabled = null,
|
||||
string? keyPrefix = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allRules = await rules.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyRule> filtered = allRules;
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(r => r.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRuleAsync(
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Check if rule already exists
|
||||
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("rule_exists", $"Rule '{request.RuleId}' already exists.", context));
|
||||
}
|
||||
|
||||
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(rule, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
var updated = MergeUpdate(existing, request, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces,
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories,
|
||||
digests: request.Match.Digests ?? existing.Match.Digests,
|
||||
labels: request.Match.Labels ?? existing.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels,
|
||||
metadata: request.Metadata ?? existing.Metadata,
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static RuleResponse MapToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly ?? false
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseThrottle(string? throttle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(throttle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try parsing as TimeSpan directly
|
||||
if (TimeSpan.TryParse(throttle, out var ts))
|
||||
{
|
||||
return ts;
|
||||
}
|
||||
|
||||
// Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
|
||||
if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
|
||||
{
|
||||
var value = throttle[2..^1];
|
||||
var unit = throttle[^1];
|
||||
|
||||
if (int.TryParse(value, out var num))
|
||||
{
|
||||
return char.ToUpperInvariant(unit) switch
|
||||
{
|
||||
'H' => TimeSpan.FromHours(num),
|
||||
'M' => TimeSpan.FromMinutes(num),
|
||||
'S' => TimeSpan.FromSeconds(num),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST endpoints for security services.
|
||||
/// </summary>
|
||||
public static class SecurityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/security")
|
||||
.WithTags("Security");
|
||||
|
||||
// Signing endpoints
|
||||
group.MapPost("/tokens/sign", SignTokenAsync)
|
||||
.WithName("SignToken")
|
||||
.WithDescription("Signs a payload and returns a token.");
|
||||
|
||||
group.MapPost("/tokens/verify", VerifyTokenAsync)
|
||||
.WithName("VerifyToken")
|
||||
.WithDescription("Verifies a token and returns the payload if valid.");
|
||||
|
||||
group.MapGet("/tokens/{token}/info", GetTokenInfo)
|
||||
.WithName("GetTokenInfo")
|
||||
.WithDescription("Gets information about a token without verification.");
|
||||
|
||||
group.MapPost("/keys/rotate", RotateKeyAsync)
|
||||
.WithName("RotateSigningKey")
|
||||
.WithDescription("Rotates the signing key.");
|
||||
|
||||
// Webhook security endpoints
|
||||
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
|
||||
.WithName("RegisterWebhookConfig")
|
||||
.WithDescription("Registers webhook security configuration.");
|
||||
|
||||
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
|
||||
.WithName("GetWebhookConfig")
|
||||
.WithDescription("Gets webhook security configuration.");
|
||||
|
||||
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
|
||||
.WithName("ValidateWebhook")
|
||||
.WithDescription("Validates a webhook request.");
|
||||
|
||||
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
|
||||
.WithName("UpdateWebhookAllowlist")
|
||||
.WithDescription("Updates IP allowlist for a webhook.");
|
||||
|
||||
// HTML sanitization endpoints
|
||||
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
|
||||
.WithName("SanitizeHtml")
|
||||
.WithDescription("Sanitizes HTML content.");
|
||||
|
||||
group.MapPost("/html/validate", ValidateHtmlAsync)
|
||||
.WithName("ValidateHtml")
|
||||
.WithDescription("Validates HTML content.");
|
||||
|
||||
group.MapPost("/html/strip", StripHtmlTagsAsync)
|
||||
.WithName("StripHtmlTags")
|
||||
.WithDescription("Strips all HTML tags from content.");
|
||||
|
||||
// Tenant isolation endpoints
|
||||
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
|
||||
.WithName("ValidateTenantAccess")
|
||||
.WithDescription("Validates tenant access to a resource.");
|
||||
|
||||
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
|
||||
.WithName("GetTenantViolations")
|
||||
.WithDescription("Gets tenant isolation violations.");
|
||||
|
||||
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
|
||||
.WithName("RunTenantFuzzTest")
|
||||
.WithDescription("Runs tenant isolation fuzz tests.");
|
||||
|
||||
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
|
||||
.WithName("GrantCrossTenantAccess")
|
||||
.WithDescription("Grants cross-tenant access to a resource.");
|
||||
|
||||
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
|
||||
.WithName("RevokeCrossTenantAccess")
|
||||
.WithDescription("Revokes cross-tenant access.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Signing endpoints
|
||||
private static async Task<IResult> SignTokenAsync(
|
||||
[FromBody] SignTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
Purpose = request.Purpose,
|
||||
TenantId = request.TenantId,
|
||||
Subject = request.Subject,
|
||||
Target = request.Target,
|
||||
ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
|
||||
Claims = request.Claims ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var token = await signingService.SignAsync(payload, cancellationToken);
|
||||
return Results.Ok(new { token });
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyTokenAsync(
|
||||
[FromBody] VerifyTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await signingService.VerifyAsync(request.Token, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static IResult GetTokenInfo(
|
||||
string token,
|
||||
[FromServices] ISigningService signingService)
|
||||
{
|
||||
var info = signingService.GetTokenInfo(token);
|
||||
return info is not null ? Results.Ok(info) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateKeyAsync(
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await signingService.RotateKeyAsync(cancellationToken);
|
||||
return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
|
||||
}
|
||||
|
||||
// Webhook security endpoints
|
||||
private static async Task<IResult> RegisterWebhookConfigAsync(
|
||||
[FromBody] WebhookSecurityConfig config,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.RegisterWebhookAsync(config, cancellationToken);
|
||||
return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWebhookConfigAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
|
||||
return config is not null ? Results.Ok(config) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateWebhookAsync(
|
||||
[FromBody] WebhookValidationRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await webhookService.ValidateAsync(request, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWebhookAllowlistAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromBody] UpdateAllowlistRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// HTML sanitization endpoints
|
||||
private static Task<IResult> SanitizeHtmlAsync(
|
||||
[FromBody] SanitizeHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var sanitized = sanitizer.Sanitize(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(new { sanitized }));
|
||||
}
|
||||
|
||||
private static Task<IResult> ValidateHtmlAsync(
|
||||
[FromBody] ValidateHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var result = sanitizer.Validate(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(result));
|
||||
}
|
||||
|
||||
private static Task<IResult> StripHtmlTagsAsync(
|
||||
[FromBody] StripHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var stripped = sanitizer.StripTags(request.Html);
|
||||
return Task.FromResult(Results.Ok(new { text = stripped }));
|
||||
}
|
||||
|
||||
// Tenant isolation endpoints
|
||||
private static async Task<IResult> ValidateTenantAccessAsync(
|
||||
[FromBody] ValidateTenantAccessRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.ValidateResourceAccessAsync(
|
||||
request.TenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.Operation,
|
||||
cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenantViolationsAsync(
|
||||
string tenantId,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
|
||||
return Results.Ok(violations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RunTenantFuzzTestAsync(
|
||||
[FromBody] TenantFuzzTestConfig config,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GrantCrossTenantAccessAsync(
|
||||
[FromBody] CrossTenantGrantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.GrantCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.AllowedOperations,
|
||||
request.ExpiresAt,
|
||||
request.GrantedBy,
|
||||
cancellationToken);
|
||||
return Results.Created();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeCrossTenantAccessAsync(
|
||||
[FromBody] RevokeCrossTenantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.RevokeCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.RevokedBy,
|
||||
cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public sealed record SignTokenRequest
|
||||
{
|
||||
public string? TokenId { get; init; }
|
||||
public required string Purpose { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public Dictionary<string, string>? Claims { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VerifyTokenRequest
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateAllowlistRequest
|
||||
{
|
||||
public required IReadOnlyList<string> AllowedIps { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SanitizeHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StripHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateTenantAccessRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
|
||||
}
|
||||
|
||||
public sealed record CrossTenantGrantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required TenantAccessOperation AllowedOperations { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required string GrantedBy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RevokeCrossTenantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required string RevokedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rule simulation.
|
||||
/// </summary>
|
||||
public static class SimulationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps simulation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/simulate")
|
||||
.WithTags("Simulation")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/", SimulateAsync)
|
||||
.WithName("SimulateRules")
|
||||
.WithSummary("Simulate rule evaluation against events")
|
||||
.WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
|
||||
|
||||
group.MapPost("/validate", ValidateRuleAsync)
|
||||
.WithName("ValidateRule")
|
||||
.WithSummary("Validate a rule definition")
|
||||
.WithDescription("Validates a rule definition and returns any errors or warnings.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SimulateAsync(
|
||||
[FromBody] SimulationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
// Convert API events to NotifyEvent
|
||||
var events = request.Events?.Select(MapToNotifyEvent).ToList();
|
||||
|
||||
// Convert API rules to NotifyRule
|
||||
var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
|
||||
|
||||
var simulationRequest = new SimulationRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Events = events,
|
||||
Rules = rules,
|
||||
EnabledRulesOnly = request.EnabledRulesOnly ?? true,
|
||||
HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
|
||||
: null,
|
||||
MaxEvents = request.MaxEvents ?? 100,
|
||||
EventKindFilter = request.EventKindFilter,
|
||||
IncludeNonMatches = request.IncludeNonMatches ?? false,
|
||||
EvaluationTimestamp = request.EvaluationTimestamp
|
||||
};
|
||||
|
||||
var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(result));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateRuleAsync(
|
||||
[FromBody] RuleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var rule = MapToNotifyRule(request, tenantId);
|
||||
var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: request.EventId ?? Guid.NewGuid(),
|
||||
kind: request.Kind ?? "unknown",
|
||||
tenant: request.TenantId ?? "default",
|
||||
ts: request.Timestamp ?? DateTimeOffset.UtcNow,
|
||||
payload: request.Payload is not null
|
||||
? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
|
||||
: null,
|
||||
scope: request.Scope is not null
|
||||
? NotifyEventScope.Create(
|
||||
@namespace: request.Scope.Namespace,
|
||||
repo: request.Scope.Repo,
|
||||
digest: request.Scope.Digest,
|
||||
component: request.Scope.Component,
|
||||
image: request.Scope.Image,
|
||||
labels: request.Scope.Labels)
|
||||
: null,
|
||||
attributes: request.Attributes);
|
||||
}
|
||||
|
||||
private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match?.EventKinds,
|
||||
namespaces: request.Match?.Namespaces,
|
||||
repositories: request.Match?.Repositories,
|
||||
digests: request.Match?.Digests,
|
||||
labels: request.Match?.Labels,
|
||||
componentPurls: request.Match?.ComponentPurls,
|
||||
minSeverity: request.Match?.MinSeverity,
|
||||
verdicts: request.Match?.Verdicts,
|
||||
kevOnly: request.Match?.KevOnly);
|
||||
|
||||
var actions = request.Actions?.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId ?? Guid.NewGuid().ToString("N")[..8],
|
||||
channel: a.Channel ?? "default",
|
||||
template: a.Template,
|
||||
throttle: a.ThrottleSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
|
||||
: null,
|
||||
enabled: a.Enabled ?? true)).ToList() ?? [];
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
tenantId: request.TenantId ?? tenantId,
|
||||
name: request.Name ?? "Unnamed Rule",
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? true,
|
||||
description: request.Description);
|
||||
}
|
||||
|
||||
private static SimulationApiResponse MapToApiResponse(SimulationResult result)
|
||||
{
|
||||
return new SimulationApiResponse
|
||||
{
|
||||
SimulationId = result.SimulationId,
|
||||
ExecutedAt = result.ExecutedAt,
|
||||
TotalEvents = result.TotalEvents,
|
||||
TotalRules = result.TotalRules,
|
||||
MatchedEvents = result.MatchedEvents,
|
||||
TotalActionsTriggered = result.TotalActionsTriggered,
|
||||
DurationMs = result.Duration.TotalMilliseconds,
|
||||
EventResults = result.EventResults.Select(e => new EventResultApiResponse
|
||||
{
|
||||
EventId = e.EventId,
|
||||
EventKind = e.EventKind,
|
||||
EventTimestamp = e.EventTimestamp,
|
||||
Matched = e.Matched,
|
||||
MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
MatchedAt = r.MatchedAt,
|
||||
Actions = r.Actions.Select(a => new ActionMatchApiResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Enabled = a.Enabled,
|
||||
ThrottleSeconds = a.Throttle?.TotalSeconds,
|
||||
Explanation = a.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
|
||||
{
|
||||
RuleId = s.RuleId,
|
||||
RuleName = s.RuleName,
|
||||
Enabled = s.Enabled,
|
||||
MatchCount = s.MatchCount,
|
||||
ActionCount = s.ActionCount,
|
||||
MatchPercentage = s.MatchPercentage,
|
||||
TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
|
||||
{
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation,
|
||||
Count = r.Count
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API request.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public List<EventApiRequest>? Events { get; set; }
|
||||
public List<RuleApiRequest>? Rules { get; set; }
|
||||
public bool? EnabledRulesOnly { get; set; }
|
||||
public int? HistoricalLookbackMinutes { get; set; }
|
||||
public int? MaxEvents { get; set; }
|
||||
public List<string>? EventKindFilter { get; set; }
|
||||
public bool? IncludeNonMatches { get; set; }
|
||||
public DateTimeOffset? EvaluationTimestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventApiRequest
|
||||
{
|
||||
public Guid? EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
public EventScopeApiRequest? Scope { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event scope for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventScopeApiRequest
|
||||
{
|
||||
public string? Namespace { get; set; }
|
||||
public string? Repo { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public string? Component { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleApiRequest
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public RuleMatchApiRequest? Match { get; set; }
|
||||
public List<RuleActionApiRequest>? Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? Namespaces { get; set; }
|
||||
public List<string>? Repositories { get; set; }
|
||||
public List<string>? Digests { get; set; }
|
||||
public List<string>? Labels { get; set; }
|
||||
public List<string>? ComponentPurls { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<string>? Verdicts { get; set; }
|
||||
public bool? KevOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleActionApiRequest
|
||||
{
|
||||
public string? ActionId { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public int? ThrottleSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API response.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiResponse
|
||||
{
|
||||
public required string SimulationId { get; set; }
|
||||
public required DateTimeOffset ExecutedAt { get; set; }
|
||||
public required int TotalEvents { get; set; }
|
||||
public required int TotalRules { get; set; }
|
||||
public required int MatchedEvents { get; set; }
|
||||
public required int TotalActionsTriggered { get; set; }
|
||||
public required double DurationMs { get; set; }
|
||||
public required List<EventResultApiResponse> EventResults { get; set; }
|
||||
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event result in simulation response.
|
||||
/// </summary>
|
||||
public sealed class EventResultApiResponse
|
||||
{
|
||||
public required Guid EventId { get; set; }
|
||||
public required string EventKind { get; set; }
|
||||
public required DateTimeOffset EventTimestamp { get; set; }
|
||||
public required bool Matched { get; set; }
|
||||
public required List<RuleMatchApiResponse> MatchedRules { get; set; }
|
||||
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required DateTimeOffset MatchedAt { get; set; }
|
||||
public required List<ActionMatchApiResponse> Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class ActionMatchApiResponse
|
||||
{
|
||||
public required string ActionId { get; set; }
|
||||
public required string Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public double? ThrottleSeconds { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule non-match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleNonMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleSummaryApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int MatchCount { get; set; }
|
||||
public required int ActionCount { get; set; }
|
||||
public required double MatchPercentage { get; set; }
|
||||
public required List<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-match reason summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class NonMatchReasonApiResponse
|
||||
{
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
public required int Count { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for storm breaker operations.
|
||||
/// </summary>
|
||||
public static class StormBreakerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps storm breaker API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/storm-breaker")
|
||||
.WithTags("Storm Breaker")
|
||||
.WithOpenApi();
|
||||
|
||||
// List active storms for tenant
|
||||
group.MapGet("/storms", async (
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
activeStorms = storms.Select(s => new
|
||||
{
|
||||
s.TenantId,
|
||||
s.StormKey,
|
||||
s.StartedAt,
|
||||
eventCount = s.EventIds.Count,
|
||||
s.SuppressedCount,
|
||||
s.LastActivityAt,
|
||||
s.IsActive
|
||||
}).ToList(),
|
||||
count = storms.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListActiveStorms")
|
||||
.WithSummary("Lists all active notification storms for a tenant");
|
||||
|
||||
// Get specific storm state
|
||||
group.MapGet("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
state.TenantId,
|
||||
state.StormKey,
|
||||
state.StartedAt,
|
||||
eventCount = state.EventIds.Count,
|
||||
state.SuppressedCount,
|
||||
state.LastActivityAt,
|
||||
state.LastSummaryAt,
|
||||
state.IsActive,
|
||||
sampleEventIds = state.EventIds.Take(10).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("GetStormState")
|
||||
.WithSummary("Gets the current state of a specific storm");
|
||||
|
||||
// Generate storm summary
|
||||
group.MapPost("/storms/{stormKey}/summary", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
|
||||
if (summary is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(summary);
|
||||
})
|
||||
.WithName("GenerateStormSummary")
|
||||
.WithSummary("Generates a summary for an active storm");
|
||||
|
||||
// Clear storm state
|
||||
group.MapDelete("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
|
||||
})
|
||||
.WithName("ClearStorm")
|
||||
.WithSummary("Clears a storm state manually");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps template management endpoints.
|
||||
/// </summary>
|
||||
public static class TemplateEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/templates")
|
||||
.WithTags("Templates");
|
||||
|
||||
group.MapGet("/", ListTemplatesAsync)
|
||||
.WithName("ListTemplates")
|
||||
.WithSummary("Lists all templates for a tenant");
|
||||
|
||||
group.MapGet("/{templateId}", GetTemplateAsync)
|
||||
.WithName("GetTemplate")
|
||||
.WithSummary("Gets a template by ID");
|
||||
|
||||
group.MapPost("/", CreateTemplateAsync)
|
||||
.WithName("CreateTemplate")
|
||||
.WithSummary("Creates a new template");
|
||||
|
||||
group.MapPut("/{templateId}", UpdateTemplateAsync)
|
||||
.WithName("UpdateTemplate")
|
||||
.WithSummary("Updates an existing template");
|
||||
|
||||
group.MapDelete("/{templateId}", DeleteTemplateAsync)
|
||||
.WithName("DeleteTemplate")
|
||||
.WithSummary("Deletes a template");
|
||||
|
||||
group.MapPost("/preview", PreviewTemplateAsync)
|
||||
.WithName("PreviewTemplate")
|
||||
.WithSummary("Previews a template rendering");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTemplatesAsync(
|
||||
HttpContext context,
|
||||
INotifyTemplateRepository templates,
|
||||
string? keyPrefix = null,
|
||||
string? channelType = null,
|
||||
string? locale = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyTemplate> filtered = allTemplates;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(channelType, true, out var ct))
|
||||
{
|
||||
filtered = filtered.Where(t => t.ChannelType == ct);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if template already exists
|
||||
var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context));
|
||||
}
|
||||
|
||||
var template = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await templates.UpsertAsync(template, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing);
|
||||
|
||||
await templates.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
await templates.DeleteAsync(tenantId, templateId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateRenderer renderer,
|
||||
INotifyTemplateService? templateService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
// Create a temporary template for preview
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "Either templateId or templateBody is required.", context));
|
||||
}
|
||||
|
||||
// Validate template body
|
||||
List<string>? warnings = null;
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(template.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
|
||||
warnings = validation.Warnings.ToList();
|
||||
}
|
||||
|
||||
// Create sample event
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "sample.event",
|
||||
tenant: tenantId,
|
||||
ts: timeProvider.GetUtcNow(),
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings?.Count > 0 ? warnings : null
|
||||
});
|
||||
}
|
||||
|
||||
private static NotifyTemplate MapFromRequest(
|
||||
TemplateCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
TimeProvider timeProvider,
|
||||
NotifyTemplate? existing = null)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var channelType = Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var ct)
|
||||
? ct
|
||||
: NotifyChannelType.Custom;
|
||||
|
||||
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
|
||||
? rm
|
||||
: NotifyTemplateRenderMode.Markdown;
|
||||
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata,
|
||||
createdBy: existing?.CreatedBy ?? actor,
|
||||
createdAt: existing?.CreatedAt ?? now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for throttle configuration management.
|
||||
/// </summary>
|
||||
public static class ThrottleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps throttle configuration endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/throttles")
|
||||
.WithTags("Throttles")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/config", GetConfigurationAsync)
|
||||
.WithName("GetThrottleConfiguration")
|
||||
.WithSummary("Get throttle configuration")
|
||||
.WithDescription("Returns the throttle configuration for the tenant.");
|
||||
|
||||
group.MapPut("/config", UpdateConfigurationAsync)
|
||||
.WithName("UpdateThrottleConfiguration")
|
||||
.WithSummary("Update throttle configuration")
|
||||
.WithDescription("Creates or updates the throttle configuration for the tenant.");
|
||||
|
||||
group.MapDelete("/config", DeleteConfigurationAsync)
|
||||
.WithName("DeleteThrottleConfiguration")
|
||||
.WithSummary("Delete throttle configuration")
|
||||
.WithDescription("Deletes the throttle configuration for the tenant, reverting to defaults.");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateThrottle")
|
||||
.WithSummary("Evaluate throttle duration")
|
||||
.WithDescription("Returns the effective throttle duration for an event kind.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.Ok(new ThrottleConfigurationApiResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDurationSeconds = 900, // 15 minutes default
|
||||
Enabled = true,
|
||||
EventKindOverrides = new Dictionary<string, int>(),
|
||||
IsDefault = true
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(config));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateConfigurationAsync(
|
||||
[FromBody] ThrottleConfigurationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (request.DefaultDurationSeconds is <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
|
||||
}
|
||||
|
||||
var config = new ThrottleConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
|
||||
EventKindOverrides = request.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
|
||||
MaxEventsPerWindow = request.MaxEventsPerWindow,
|
||||
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
|
||||
: null,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] ThrottleEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ThrottleEvaluateApiResponse
|
||||
{
|
||||
EventKind = request.EventKind,
|
||||
EffectiveDurationSeconds = (int)duration.TotalSeconds
|
||||
});
|
||||
}
|
||||
|
||||
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
|
||||
EventKindOverrides = config.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
|
||||
?? new Dictionary<string, int>(),
|
||||
MaxEventsPerWindow = config.MaxEventsPerWindow,
|
||||
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
|
||||
? (int)config.BurstWindowDuration.Value.TotalSeconds
|
||||
: null,
|
||||
Enabled = config.Enabled,
|
||||
CreatedAt = config.CreatedAt,
|
||||
CreatedBy = config.CreatedBy,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
UpdatedBy = config.UpdatedBy,
|
||||
IsDefault = false
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public int? DefaultDurationSeconds { get; set; }
|
||||
public Dictionary<string, int>? EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate throttle duration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiResponse
|
||||
{
|
||||
public required string TenantId { get; set; }
|
||||
public required int DefaultDurationSeconds { get; set; }
|
||||
public required Dictionary<string, int> EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle evaluation.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiResponse
|
||||
{
|
||||
public required string EventKind { get; set; }
|
||||
public required int EffectiveDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -9,7 +9,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.WebService.Endpoints;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
@@ -39,12 +47,46 @@ if (!isTesting)
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
|
||||
// Template service for v2 API preview endpoint
|
||||
builder.Services.AddTemplateServices(options =>
|
||||
{
|
||||
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
|
||||
if (!string.IsNullOrWhiteSpace(provenanceUrl))
|
||||
{
|
||||
options.ProvenanceBaseUrl = provenanceUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// Escalation and on-call services
|
||||
builder.Services.AddEscalationServices(builder.Configuration);
|
||||
|
||||
// Storm breaker, localization, and fallback services
|
||||
builder.Services.AddStormBreakerServices(builder.Configuration);
|
||||
|
||||
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
|
||||
builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
||||
|
||||
// Observability services (metrics, tracing, dead-letter, chaos testing, retention)
|
||||
builder.Services.AddNotifierObservabilityServices(builder.Configuration);
|
||||
|
||||
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
||||
builder.Services.AddNotifierTenancy(builder.Configuration);
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Enable WebSocket support for live incident feed
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
// Tenant context middleware (extracts and validates tenant from headers/query)
|
||||
app.UseTenantContext();
|
||||
|
||||
// Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
@@ -320,17 +362,26 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
return Results.StatusCode(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// Use actor from request or fall back to endpoint name
|
||||
var actor = !string.IsNullOrWhiteSpace(request.Actor) ? request.Actor : "pack-approvals-ack";
|
||||
|
||||
try
|
||||
{
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = "pack-approvals-ack",
|
||||
Actor = actor,
|
||||
Action = "pack.approval.acknowledged",
|
||||
EntityId = packId,
|
||||
EntityType = "pack-approval",
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(new
|
||||
{
|
||||
request.AckToken,
|
||||
request.Decision,
|
||||
request.Comment,
|
||||
request.Actor
|
||||
}))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
@@ -343,6 +394,25 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// v2 REST APIs (/api/v2/notify/... for existing consumers)
|
||||
app.MapNotifyApiV2();
|
||||
|
||||
// v2 REST APIs (/api/v2/... simplified paths)
|
||||
app.MapRuleEndpoints();
|
||||
app.MapTemplateEndpoints();
|
||||
app.MapIncidentEndpoints();
|
||||
app.MapIncidentLiveFeed();
|
||||
app.MapSimulationEndpoints();
|
||||
app.MapQuietHoursEndpoints();
|
||||
app.MapThrottleEndpoints();
|
||||
app.MapOperatorOverrideEndpoints();
|
||||
app.MapEscalationEndpoints();
|
||||
app.MapStormBreakerEndpoints();
|
||||
app.MapLocalizationEndpoints();
|
||||
app.MapFallbackEndpoints();
|
||||
app.MapSecurityEndpoints();
|
||||
app.MapObservabilityEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
|
||||
@@ -356,6 +426,13 @@ info:
|
||||
paths:
|
||||
/api/v1/notify/quiet-hours: {}
|
||||
/api/v1/notify/incidents: {}
|
||||
/api/v2/notify/rules: {}
|
||||
/api/v2/notify/templates: {}
|
||||
/api/v2/notify/incidents: {}
|
||||
/api/v2/rules: {}
|
||||
/api/v2/templates: {}
|
||||
/api/v2/incidents: {}
|
||||
/api/v2/incidents/live: {}
|
||||
""";
|
||||
|
||||
return Results.Text(stub, "application/yaml", Encoding.UTF8);
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Notifier - Pack Approvals API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Pack approval notification contract for Task Runner integration.
|
||||
|
||||
## Security Requirements
|
||||
- All endpoints require `Authorization: Bearer <token>` with scope `packs.approve` or `Notifier.Events:Write`
|
||||
- Tenant isolation enforced via `X-StellaOps-Tenant` header
|
||||
- HMAC signature validation available for webhook callbacks (see security schemes)
|
||||
- IP allowlist configurable per environment
|
||||
|
||||
## Resume Token Mechanics
|
||||
- Clients include `resumeToken` in requests to enable Task Runner resume flow
|
||||
- Server responds with `X-Resume-After` header containing the token for cursor-based processing
|
||||
- Tokens are opaque strings; clients must not parse or modify them
|
||||
- Token TTL: 24 hours; expired tokens result in 410 Gone
|
||||
|
||||
servers:
|
||||
- url: /api/v1/notify
|
||||
description: Notifier API v1
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/pack-approvals:
|
||||
post:
|
||||
operationId: ingestPackApproval
|
||||
summary: Ingest a pack approval event
|
||||
description: |
|
||||
Receives pack approval events from Task Runner. Persists approval state,
|
||||
emits notifications, and returns acknowledgement for resume flow.
|
||||
|
||||
**Idempotency**: Requests with the same `Idempotency-Key` header within 15 minutes
|
||||
return 200 OK without side effects.
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalRequest'
|
||||
examples:
|
||||
approvalRequested:
|
||||
summary: Approval requested for pack deployment
|
||||
value:
|
||||
eventId: "550e8400-e29b-41d4-a716-446655440000"
|
||||
issuedAt: "2025-11-27T10:30:00Z"
|
||||
kind: "pack.approval.requested"
|
||||
packId: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
policy:
|
||||
id: "policy-prod-deploy"
|
||||
version: "1.2.3"
|
||||
decision: "pending"
|
||||
actor: "ci-pipeline@stellaops.example.com"
|
||||
resumeToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
summary: "Deployment approval required for production scanner update"
|
||||
labels:
|
||||
environment: "production"
|
||||
team: "security"
|
||||
policyHold:
|
||||
summary: Policy gate hold notification
|
||||
value:
|
||||
eventId: "660e8400-e29b-41d4-a716-446655440001"
|
||||
issuedAt: "2025-11-27T10:35:00Z"
|
||||
kind: "pack.policy.hold"
|
||||
packId: "pkg:oci/stellaops/concelier@v3.0.0"
|
||||
policy:
|
||||
id: "policy-compliance-check"
|
||||
version: "2.0.0"
|
||||
decision: "hold"
|
||||
actor: "policy-engine@stellaops.example.com"
|
||||
summary: "Package held pending compliance attestation"
|
||||
labels:
|
||||
compliance_framework: "SOC2"
|
||||
responses:
|
||||
'202':
|
||||
description: Approval event accepted for processing
|
||||
headers:
|
||||
X-Resume-After:
|
||||
description: Resume token for cursor-based processing
|
||||
schema:
|
||||
type: string
|
||||
'200':
|
||||
description: Duplicate request (idempotent); no action taken
|
||||
'400':
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
missingTenant:
|
||||
value:
|
||||
error:
|
||||
code: "tenant_missing"
|
||||
message: "X-StellaOps-Tenant header is required."
|
||||
traceId: "00-abc123-def456-00"
|
||||
invalidRequest:
|
||||
value:
|
||||
error:
|
||||
code: "invalid_request"
|
||||
message: "eventId, packId, kind, decision, actor are required."
|
||||
traceId: "00-abc123-def456-01"
|
||||
'401':
|
||||
description: Authentication required
|
||||
'403':
|
||||
description: Insufficient permissions (missing scope)
|
||||
'429':
|
||||
description: Rate limited; retry after delay
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds to wait before retry
|
||||
|
||||
/pack-approvals/{packId}/ack:
|
||||
post:
|
||||
operationId: acknowledgePackApproval
|
||||
summary: Acknowledge a pack approval decision
|
||||
description: |
|
||||
Records approval decision and triggers Task Runner resume callback.
|
||||
|
||||
**Idempotency**: Duplicate acknowledgements for the same packId + ackToken
|
||||
return 200 OK without side effects.
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
description: Package identifier (PURL format)
|
||||
schema:
|
||||
type: string
|
||||
example: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalAckRequest'
|
||||
examples:
|
||||
approved:
|
||||
summary: Approval granted
|
||||
value:
|
||||
ackToken: "ack-token-abc123"
|
||||
decision: "approved"
|
||||
comment: "Reviewed and approved for production deployment"
|
||||
actor: "admin@stellaops.example.com"
|
||||
rejected:
|
||||
summary: Approval rejected
|
||||
value:
|
||||
ackToken: "ack-token-def456"
|
||||
decision: "rejected"
|
||||
comment: "Missing required attestation"
|
||||
actor: "security-lead@stellaops.example.com"
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledgement recorded successfully
|
||||
'200':
|
||||
description: Duplicate acknowledgement (idempotent); no action taken
|
||||
'400':
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Pack approval not found
|
||||
'410':
|
||||
description: Acknowledgement token expired
|
||||
|
||||
/pack-approvals/{packId}:
|
||||
get:
|
||||
operationId: getPackApproval
|
||||
summary: Retrieve pack approval status
|
||||
description: Returns the current state of a pack approval request
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Pack approval details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalResponse'
|
||||
'404':
|
||||
description: Pack approval not found
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: OAuth2 bearer token with `packs.approve` or `Notifier.Events:Write` scope
|
||||
hmacSignature:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-StellaOps-Signature
|
||||
description: |
|
||||
HMAC-SHA256 signature for webhook callbacks.
|
||||
Format: `sha256=<hex-encoded-signature>`
|
||||
Signature covers: `timestamp.body` where timestamp is from X-StellaOps-Timestamp header
|
||||
|
||||
parameters:
|
||||
TenantHeader:
|
||||
name: X-StellaOps-Tenant
|
||||
in: header
|
||||
required: true
|
||||
description: Tenant identifier for multi-tenant isolation
|
||||
schema:
|
||||
type: string
|
||||
example: "tenant-acme-corp"
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: Client-generated idempotency key (UUID recommended)
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
schemas:
|
||||
PackApprovalRequest:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- issuedAt
|
||||
- kind
|
||||
- packId
|
||||
- decision
|
||||
- actor
|
||||
properties:
|
||||
eventId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Unique event identifier
|
||||
issuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event timestamp (ISO 8601)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- pack.approval.requested
|
||||
- pack.approval.updated
|
||||
- pack.policy.hold
|
||||
- pack.policy.released
|
||||
description: Event type
|
||||
packId:
|
||||
type: string
|
||||
description: Package identifier (PURL format)
|
||||
example: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
policy:
|
||||
$ref: '#/components/schemas/PackApprovalPolicy'
|
||||
decision:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- approved
|
||||
- rejected
|
||||
- hold
|
||||
- expired
|
||||
description: Current approval state
|
||||
actor:
|
||||
type: string
|
||||
description: Identity that triggered the event
|
||||
resumeToken:
|
||||
type: string
|
||||
description: Opaque token for Task Runner resume flow
|
||||
summary:
|
||||
type: string
|
||||
description: Human-readable summary for notifications
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Custom metadata labels
|
||||
|
||||
PackApprovalPolicy:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Policy identifier
|
||||
version:
|
||||
type: string
|
||||
description: Policy version
|
||||
|
||||
PackApprovalAckRequest:
|
||||
type: object
|
||||
required:
|
||||
- ackToken
|
||||
properties:
|
||||
ackToken:
|
||||
type: string
|
||||
description: Acknowledgement token from notification
|
||||
decision:
|
||||
type: string
|
||||
enum:
|
||||
- approved
|
||||
- rejected
|
||||
description: Approval decision
|
||||
comment:
|
||||
type: string
|
||||
description: Optional comment for audit trail
|
||||
actor:
|
||||
type: string
|
||||
description: Identity acknowledging the approval
|
||||
|
||||
PackApprovalResponse:
|
||||
type: object
|
||||
properties:
|
||||
packId:
|
||||
type: string
|
||||
eventId:
|
||||
type: string
|
||||
format: uuid
|
||||
kind:
|
||||
type: string
|
||||
decision:
|
||||
type: string
|
||||
policy:
|
||||
$ref: '#/components/schemas/PackApprovalPolicy'
|
||||
issuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
acknowledgedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
acknowledgedBy:
|
||||
type: string
|
||||
resumeToken:
|
||||
type: string
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable error message
|
||||
traceId:
|
||||
type: string
|
||||
description: Request trace ID for debugging
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for resolving channel adapters by type.
|
||||
/// </summary>
|
||||
public interface IChannelAdapterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a channel adapter for the specified channel type.
|
||||
/// </summary>
|
||||
IChannelAdapter? GetAdapter(NotifyChannelType channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered channel adapters.
|
||||
/// </summary>
|
||||
IReadOnlyList<IChannelAdapter> GetAllAdapters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChannelAdapterFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterFactory : IChannelAdapterFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, IChannelAdapter> _adapters;
|
||||
private readonly IReadOnlyList<IChannelAdapter> _allAdapters;
|
||||
|
||||
public ChannelAdapterFactory(IEnumerable<IChannelAdapter> adapters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(adapters);
|
||||
|
||||
var adapterList = adapters.ToList();
|
||||
_allAdapters = adapterList.AsReadOnly();
|
||||
|
||||
var dict = new Dictionary<NotifyChannelType, IChannelAdapter>();
|
||||
foreach (var adapter in adapterList)
|
||||
{
|
||||
dict[adapter.ChannelType] = adapter;
|
||||
}
|
||||
_adapters = dict;
|
||||
}
|
||||
|
||||
public IChannelAdapter? GetAdapter(NotifyChannelType channelType)
|
||||
{
|
||||
return _adapters.GetValueOrDefault(channelType);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IChannelAdapter> GetAllAdapters() => _allAdapters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering channel adapters.
|
||||
/// </summary>
|
||||
public static class ChannelAdapterServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers channel adapters and factory.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddChannelAdapters(
|
||||
this IServiceCollection services,
|
||||
Action<ChannelAdapterOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<ChannelAdapterOptions>()
|
||||
.BindConfiguration(ChannelAdapterOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<WebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddHttpClient<ChatWebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, WebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<WebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(WebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, EmailChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapter, ChatWebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<ChatWebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(ChatWebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
// PagerDuty adapter
|
||||
services.AddHttpClient<PagerDutyChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, PagerDutyChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<PagerDutyChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(PagerDutyChannelAdapter)));
|
||||
});
|
||||
|
||||
// OpsGenie adapter
|
||||
services.AddHttpClient<OpsGenieChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, OpsGenieChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<OpsGenieChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(OpsGenieChannelAdapter)));
|
||||
});
|
||||
|
||||
// InApp adapter
|
||||
services.AddOptions<InAppChannelOptions>()
|
||||
.BindConfiguration(InAppChannelOptions.SectionName);
|
||||
services.AddSingleton<IChannelAdapter, InAppChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapterFactory, ChannelAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for channel adapters.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "ChannelAdapters";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed dispatches.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay for exponential backoff between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delay between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual dispatch operations.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Enable HMAC signing for webhook payloads.
|
||||
/// </summary>
|
||||
public bool EnableHmacSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string for HTTP requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-Notifier/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default concurrency limit per channel type.
|
||||
/// </summary>
|
||||
public int DefaultConcurrency { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Enable circuit breaker for unhealthy channels.
|
||||
/// </summary>
|
||||
public bool EnableCircuitBreaker { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before circuit opens.
|
||||
/// </summary>
|
||||
public int CircuitBreakerThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Duration to keep circuit open before allowing retry.
|
||||
/// </summary>
|
||||
public TimeSpan CircuitBreakerDuration { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack and Teams webhooks with retry policies.
|
||||
/// Handles Slack incoming webhooks and Teams connectors.
|
||||
/// </summary>
|
||||
public sealed class ChatWebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<ChatWebhookChannelAdapter> _logger;
|
||||
|
||||
public ChatWebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
ILogger<ChatWebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
// Routes Slack type to this adapter; Teams uses Custom type
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this adapter can handle the specified channel.
|
||||
/// </summary>
|
||||
public bool CanHandle(NotifyChannel channel)
|
||||
{
|
||||
return channel.Type is NotifyChannelType.Slack or NotifyChannelType.Teams;
|
||||
}
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid webhook URL.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Chat webhook URL is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var isSlack = context.Channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
var payload = isSlack
|
||||
? BuildSlackPayload(context)
|
||||
: BuildTeamsPayload(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, isSlack, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chat webhook delivery {DeliveryId} to {Platform} succeeded on attempt {Attempt}.",
|
||||
context.DeliveryId, isSlack ? "Slack" : "Teams", attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {(isSlack ? "Slack" : "Teams")}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} throttled. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {(isSlack ? "Slack" : "Teams")}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var errorMessage = $"Chat webhook returned {response.StatusCode}: {TruncateError(responseBody)}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} failed: {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Chat webhook delivery {DeliveryId} exhausted all retries.",
|
||||
context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook URL is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var isSlack = channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
|
||||
// Slack/Teams webhooks don't support HEAD, so we just validate the URL format
|
||||
if (isSlack && !uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Slack webhook.");
|
||||
}
|
||||
|
||||
if (!isSlack && !uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("outlook.office.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Teams connector.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"{(isSlack ? "Slack" : "Teams")} webhook URL validated.");
|
||||
}
|
||||
|
||||
private static bool IsSlackWebhook(Uri uri)
|
||||
{
|
||||
return uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildSlackPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var message = new
|
||||
{
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = context.RenderedBody
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Delivery ID:* {context.DeliveryId} | *Trace:* {context.TraceId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string BuildTeamsPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var card = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
weight = "bolder",
|
||||
size = "medium"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.RenderedBody,
|
||||
wrap = true
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = new object[]
|
||||
{
|
||||
new { title = "Delivery ID", value = context.DeliveryId },
|
||||
new { title = "Trace ID", value = context.TraceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(card, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static string TruncateError(string error)
|
||||
{
|
||||
const int maxLength = 200;
|
||||
return error.Length > maxLength ? error[..maxLength] + "..." : error;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
bool isSlack,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["platform"] = isSlack ? "Slack" : "Teams",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for SMTP email dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable
|
||||
{
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _disposed;
|
||||
|
||||
public EmailChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(context.Channel, out var smtpConfig, out var configError))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, configError, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(configError, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var recipients = ParseRecipients(context);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
var error = "No valid recipients configured.";
|
||||
await AuditDispatchAsync(context, false, error, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(error, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateSmtpClient(smtpConfig);
|
||||
using var message = BuildMessage(context, smtpConfig, recipients);
|
||||
|
||||
await client.SendMailAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, smtpConfig, recipients, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Email delivery {DeliveryId} succeeded to {RecipientCount} recipients via {SmtpHost} on attempt {Attempt}.",
|
||||
context.DeliveryId, recipients.Count, smtpConfig.Host, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Sent to {recipients.Count} recipient(s) via {smtpConfig.Host}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (SmtpException ex) when (IsRetryable(ex))
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with retryable SMTP error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (SmtpException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"SMTP error: {ex.StatusCode} - {ex.Message}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} failed with non-retryable SMTP error: {StatusCode}.",
|
||||
context.DeliveryId, ex.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or TimeoutException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Email delivery {DeliveryId} exhausted all {MaxRetries} retries to {SmtpHost}.",
|
||||
context.DeliveryId, maxRetries + 1, smtpConfig.Host);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
ChannelDispatchStatus.NetworkError,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(channel, out var smtpConfig, out var error))
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Unhealthy(error));
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok(
|
||||
$"SMTP configuration validated for {smtpConfig.Host}:{smtpConfig.Port}."));
|
||||
}
|
||||
|
||||
private static bool TryParseSmtpConfig(NotifyChannel channel, out SmtpConfig config, out string error)
|
||||
{
|
||||
config = default;
|
||||
error = string.Empty;
|
||||
|
||||
var props = channel.Config.Properties;
|
||||
if (props is null)
|
||||
{
|
||||
error = "Channel properties are not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpHost", out var host) || string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
error = "SMTP host is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpPort", out var portStr) || !int.TryParse(portStr, out var port))
|
||||
{
|
||||
port = 587;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("fromAddress", out var fromAddress) || string.IsNullOrWhiteSpace(fromAddress))
|
||||
{
|
||||
error = "From address is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
props.TryGetValue("fromName", out var fromName);
|
||||
props.TryGetValue("username", out var username);
|
||||
var enableSsl = !props.TryGetValue("enableSsl", out var sslStr) || !bool.TryParse(sslStr, out var ssl) || ssl;
|
||||
|
||||
config = new SmtpConfig(host, port, fromAddress, fromName, username, channel.Config.SecretRef, enableSsl);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> ParseRecipients(ChannelDispatchContext context)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Channel.Config.Target))
|
||||
{
|
||||
recipients.AddRange(context.Channel.Config.Target
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("recipients", out var metaRecipients) &&
|
||||
!string.IsNullOrWhiteSpace(metaRecipients))
|
||||
{
|
||||
recipients.AddRange(metaRecipients
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
return recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new MailAddress(email);
|
||||
return addr.Address == email.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private SmtpClient CreateSmtpClient(SmtpConfig config)
|
||||
{
|
||||
var client = new SmtpClient(config.Host, config.Port)
|
||||
{
|
||||
EnableSsl = config.EnableSsl,
|
||||
Timeout = (int)_options.DispatchTimeout.TotalMilliseconds,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(config.Username, config.Password);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static MailMessage BuildMessage(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients)
|
||||
{
|
||||
var from = string.IsNullOrWhiteSpace(config.FromName)
|
||||
? new MailAddress(config.FromAddress)
|
||||
: new MailAddress(config.FromAddress, config.FromName);
|
||||
|
||||
var message = new MailMessage
|
||||
{
|
||||
From = from,
|
||||
Subject = context.Subject ?? "StellaOps Notification",
|
||||
Body = context.RenderedBody,
|
||||
IsBodyHtml = context.RenderedBody.Contains("<html", StringComparison.OrdinalIgnoreCase) ||
|
||||
context.RenderedBody.Contains("<body", StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
message.To.Add(recipient);
|
||||
}
|
||||
|
||||
message.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
message.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(SmtpException ex)
|
||||
{
|
||||
return ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.ServiceNotAvailable => true,
|
||||
SmtpStatusCode.MailboxBusy => true,
|
||||
SmtpStatusCode.LocalErrorInProcessing => true,
|
||||
SmtpStatusCode.InsufficientStorage => true,
|
||||
SmtpStatusCode.ServiceClosingTransmissionChannel => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = config.Host,
|
||||
["smtpPort"] = config.Port.ToString(),
|
||||
["recipientCount"] = recipients.Count.ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private readonly record struct SmtpConfig(
|
||||
string Host,
|
||||
int Port,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Username,
|
||||
string? Password,
|
||||
bool EnableSsl);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel adapters to dispatch notifications.
|
||||
/// </summary>
|
||||
public interface IChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type handled by this adapter.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a notification delivery through the channel.
|
||||
/// </summary>
|
||||
Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks channel health/connectivity.
|
||||
/// </summary>
|
||||
Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for dispatching a notification through a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchContext(
|
||||
string DeliveryId,
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
NotifyDelivery Delivery,
|
||||
string RenderedBody,
|
||||
string? Subject,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required ChannelDispatchStatus Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? ExternalId { get; init; }
|
||||
public int? HttpStatusCode { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
public Exception? Exception { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Succeeded(
|
||||
string? externalId = null,
|
||||
string? message = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
ExternalId = externalId,
|
||||
Message = message ?? "Delivery dispatched successfully.",
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Failed(
|
||||
string message,
|
||||
ChannelDispatchStatus status = ChannelDispatchStatus.Failed,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Throttled(
|
||||
string message,
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var meta = metadata is not null
|
||||
? new Dictionary<string, string>(metadata)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
if (retryAfter.HasValue)
|
||||
{
|
||||
meta["retryAfterSeconds"] = retryAfter.Value.TotalSeconds.ToString("F0");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = ChannelDispatchStatus.Throttled,
|
||||
Message = message,
|
||||
HttpStatusCode = 429,
|
||||
Metadata = meta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch attempt status.
|
||||
/// </summary>
|
||||
public enum ChannelDispatchStatus
|
||||
{
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
InvalidConfiguration,
|
||||
Timeout,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel health check.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthCheckResult
|
||||
{
|
||||
public required bool Healthy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public TimeSpan? Latency { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public static ChannelHealthCheckResult Ok(string? message = null, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "healthy",
|
||||
Message = message ?? "Channel is operational.",
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Degraded(string message, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "degraded",
|
||||
Message = message,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Unhealthy(string message) => new()
|
||||
{
|
||||
Healthy = false,
|
||||
Status = "unhealthy",
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app notifications (inbox/CLI).
|
||||
/// Stores notifications in-memory for retrieval by users/services.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<InAppNotification>> _inboxes = new();
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly InAppChannelOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InAppChannelAdapter> _logger;
|
||||
|
||||
public InAppChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<InAppChannelOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InAppChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? new InAppChannelOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InApp;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetTargetUserId(context);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "No target user ID specified.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Target user ID is required for in-app notifications.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}"[..20],
|
||||
DeliveryId = context.DeliveryId,
|
||||
TenantId = context.TenantId,
|
||||
UserId = userId,
|
||||
Title = context.Subject ?? "Notification",
|
||||
Body = context.RenderedBody,
|
||||
Priority = GetPriority(context),
|
||||
Category = GetCategory(context),
|
||||
IncidentId = context.Metadata.GetValueOrDefault("incidentId"),
|
||||
ActionUrl = context.Metadata.GetValueOrDefault("actionUrl"),
|
||||
AckUrl = context.Metadata.GetValueOrDefault("ackUrl"),
|
||||
Metadata = new Dictionary<string, string>(context.Metadata),
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = _timeProvider.GetUtcNow() + _options.NotificationTtl,
|
||||
Status = InAppNotificationStatus.Unread
|
||||
};
|
||||
|
||||
// Store in inbox
|
||||
var inboxKey = BuildInboxKey(context.TenantId, userId);
|
||||
var inbox = _inboxes.GetOrAdd(inboxKey, _ => new ConcurrentQueue<InAppNotification>());
|
||||
inbox.Enqueue(notification);
|
||||
|
||||
// Enforce max notifications per inbox
|
||||
while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _))
|
||||
{
|
||||
// Remove oldest
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["notificationId"] = notification.NotificationId,
|
||||
["userId"] = userId,
|
||||
["inboxSize"] = inbox.Count.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app notification {NotificationId} delivered to user {UserId} inbox for tenant {TenantId}.",
|
||||
notification.NotificationId, userId, context.TenantId);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: notification.NotificationId,
|
||||
message: $"Delivered to inbox for user {userId}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, ex.Message, null, cancellationToken);
|
||||
|
||||
_logger.LogError(ex, "In-app notification dispatch failed for delivery {DeliveryId}.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
ex.Message,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok("In-app channel operational."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetUnreadNotifications(string tenantId, string userId, int limit = 50)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => n.Status == InAppNotificationStatus.Unread && (!n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetNotifications(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 100,
|
||||
bool includeRead = true,
|
||||
bool includeExpired = false)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => (includeRead || n.Status == InAppNotificationStatus.Unread) &&
|
||||
(includeExpired || !n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a notification as read.
|
||||
/// </summary>
|
||||
public bool MarkAsRead(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = _timeProvider.GetUtcNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all notifications as read for a user.
|
||||
/// </summary>
|
||||
public int MarkAllAsRead(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var notification in inbox.Where(n => n.Status == InAppNotificationStatus.Unread))
|
||||
{
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = now;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a notification.
|
||||
/// </summary>
|
||||
public bool DeleteNotification(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ConcurrentQueue doesn't support removal, so mark as deleted
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Deleted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread count for a user.
|
||||
/// </summary>
|
||||
public int GetUnreadCount(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox.Count(n => n.Status == InAppNotificationStatus.Unread &&
|
||||
(!n.ExpiresAt.HasValue || n.ExpiresAt > now));
|
||||
}
|
||||
|
||||
private static string GetTargetUserId(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("targetUserId", out var userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("userId", out userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static InAppNotificationPriority GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("priority", out var priority) ||
|
||||
context.Metadata.TryGetValue("severity", out priority))
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" or "urgent" => InAppNotificationPriority.Urgent,
|
||||
"high" => InAppNotificationPriority.High,
|
||||
"medium" => InAppNotificationPriority.Normal,
|
||||
"low" => InAppNotificationPriority.Low,
|
||||
_ => InAppNotificationPriority.Normal
|
||||
};
|
||||
}
|
||||
return InAppNotificationPriority.Normal;
|
||||
}
|
||||
|
||||
private static string GetCategory(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("category", out var category) && !string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("eventKind", out var eventKind) && !string.IsNullOrWhiteSpace(eventKind))
|
||||
{
|
||||
return eventKind;
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static string BuildInboxKey(string tenantId, string userId) =>
|
||||
$"{tenantId}:{userId}";
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "InApp",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for in-app channel adapter.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "InAppChannel";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum notifications to keep per user inbox.
|
||||
/// </summary>
|
||||
public int MaxNotificationsPerInbox { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for notifications.
|
||||
/// </summary>
|
||||
public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An in-app notification stored in user's inbox.
|
||||
/// </summary>
|
||||
public sealed class InAppNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique notification ID.
|
||||
/// </summary>
|
||||
public required string NotificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original delivery ID.
|
||||
/// </summary>
|
||||
public required string DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target user ID.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification body/content.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority level.
|
||||
/// </summary>
|
||||
public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Notification category.
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related incident ID.
|
||||
/// </summary>
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for main action.
|
||||
/// </summary>
|
||||
public string? ActionUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for acknowledgment action.
|
||||
/// </summary>
|
||||
public string? AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread;
|
||||
|
||||
/// <summary>
|
||||
/// When read.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification status.
|
||||
/// </summary>
|
||||
public enum InAppNotificationStatus
|
||||
{
|
||||
Unread,
|
||||
Read,
|
||||
Actioned,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification priority.
|
||||
/// </summary>
|
||||
public enum InAppNotificationPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for OpsGenie Alert API v2.
|
||||
/// </summary>
|
||||
public sealed class OpsGenieChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private const string DefaultApiUrl = "https://api.opsgenie.com/v2/alerts";
|
||||
private const string EuApiUrl = "https://api.eu.opsgenie.com/v2/alerts";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<OpsGenieChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public OpsGenieChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsGenieChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!TryGetApiKey(context.Channel, out var apiKey))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Missing OpsGenie API key.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"OpsGenie API key is not configured.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var baseUrl = GetApiUrl(context.Channel);
|
||||
var action = GetAlertAction(context);
|
||||
var alias = GetAlias(context);
|
||||
var requestUrl = BuildRequestUrl(baseUrl, action, alias);
|
||||
|
||||
var payload = BuildPayload(context, action, alias);
|
||||
var payloadJson = payload is not null ? JsonSerializer.Serialize(payload, JsonOptions) : null;
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(GetHttpMethod(action), requestUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
if (payloadJson is not null)
|
||||
{
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var opsGenieResponse = JsonSerializer.Deserialize<OpsGenieResponse>(responseBody, JsonOptions);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["requestId"] = opsGenieResponse?.RequestId ?? string.Empty,
|
||||
["alias"] = alias ?? string.Empty,
|
||||
["action"] = action,
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"OpsGenie delivery {DeliveryId} succeeded with requestId={RequestId} on attempt {Attempt}.",
|
||||
context.DeliveryId, opsGenieResponse?.RequestId, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: opsGenieResponse?.RequestId,
|
||||
message: $"OpsGenie {action} succeeded",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, "Rate limited by OpsGenie.", null, cancellationToken);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
"Rate limited by OpsGenie.",
|
||||
retryAfter ?? TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorMessage = $"OpsGenie returned {response.StatusCode}: {errorBody}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"OpsGenie delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"OpsGenie delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(ex, "OpsGenie delivery {DeliveryId} attempt {Attempt} failed.", context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug("OpsGenie delivery {DeliveryId} attempt {Attempt} timed out.", context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(lastException, "OpsGenie delivery {DeliveryId} exhausted all retries.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!TryGetApiKey(channel, out _))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("OpsGenie API key is not configured.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok("OpsGenie channel configured.");
|
||||
}
|
||||
|
||||
private static bool TryGetApiKey(NotifyChannel channel, out string apiKey)
|
||||
{
|
||||
apiKey = string.Empty;
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("apiKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
apiKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetApiUrl(NotifyChannel channel)
|
||||
{
|
||||
if (channel.Config.Properties.TryGetValue("apiUrl", out var url) && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("region", out var region) &&
|
||||
region.Equals("eu", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EuApiUrl;
|
||||
}
|
||||
|
||||
return DefaultApiUrl;
|
||||
}
|
||||
|
||||
private static string GetAlertAction(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("opsgenie.action", out var action) && !string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return action.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.resolved", out var resolved) && resolved == "true")
|
||||
{
|
||||
return "close";
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.acknowledged", out var acked) && acked == "true")
|
||||
{
|
||||
return "acknowledge";
|
||||
}
|
||||
|
||||
return "create";
|
||||
}
|
||||
|
||||
private static string? GetAlias(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("opsgenie.alias", out var alias) && !string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
return alias;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incidentId", out var incidentId) && !string.IsNullOrWhiteSpace(incidentId))
|
||||
{
|
||||
return $"stellaops-{context.TenantId}-{incidentId}";
|
||||
}
|
||||
|
||||
return $"stellaops-{context.DeliveryId}";
|
||||
}
|
||||
|
||||
private static string BuildRequestUrl(string baseUrl, string action, string? alias)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => baseUrl,
|
||||
"acknowledge" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/acknowledge?identifierType=alias",
|
||||
"close" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/close?identifierType=alias",
|
||||
"note" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/notes?identifierType=alias",
|
||||
_ => baseUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpMethod GetHttpMethod(string action)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => HttpMethod.Post,
|
||||
_ => HttpMethod.Post
|
||||
};
|
||||
}
|
||||
|
||||
private object? BuildPayload(ChannelDispatchContext context, string action, string? alias)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => BuildCreatePayload(context, alias),
|
||||
"acknowledge" => BuildAcknowledgePayload(context),
|
||||
"close" => BuildClosePayload(context),
|
||||
"note" => BuildNotePayload(context),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private OpsGenieCreateAlert BuildCreatePayload(ChannelDispatchContext context, string? alias)
|
||||
{
|
||||
var message = context.Subject ?? "StellaOps Alert";
|
||||
var priority = GetPriority(context);
|
||||
|
||||
return new OpsGenieCreateAlert
|
||||
{
|
||||
Message = message,
|
||||
Alias = alias,
|
||||
Description = context.RenderedBody,
|
||||
Priority = priority,
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Entity = context.Metadata.GetValueOrDefault("component"),
|
||||
Tags = BuildTags(context),
|
||||
Details = BuildDetails(context),
|
||||
User = "StellaOps Notifier",
|
||||
Note = $"Created by StellaOps delivery {context.DeliveryId}"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieAcknowledge BuildAcknowledgePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieAcknowledge
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.Metadata.GetValueOrDefault("comment") ?? "Acknowledged via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieClose BuildClosePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieClose
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.Metadata.GetValueOrDefault("comment") ?? "Closed via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieNote BuildNotePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieNote
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.RenderedBody ?? "Note added via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("severity", out var sev))
|
||||
{
|
||||
return sev.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "P1",
|
||||
"high" => "P2",
|
||||
"medium" => "P3",
|
||||
"low" => "P4",
|
||||
_ => "P3"
|
||||
};
|
||||
}
|
||||
return "P3";
|
||||
}
|
||||
|
||||
private static List<string>? BuildTags(ChannelDispatchContext context)
|
||||
{
|
||||
var tags = new List<string> { "stellaops" };
|
||||
|
||||
if (context.Metadata.TryGetValue("tags", out var tagStr) && !string.IsNullOrWhiteSpace(tagStr))
|
||||
{
|
||||
tags.AddRange(tagStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("severity", out var severity) && !string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
tags.Add($"severity:{severity}");
|
||||
}
|
||||
|
||||
return tags.Count > 0 ? tags : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string>? BuildDetails(ChannelDispatchContext context)
|
||||
{
|
||||
var details = new Dictionary<string, string>
|
||||
{
|
||||
["tenantId"] = context.TenantId,
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
foreach (var (key, value) in context.Metadata)
|
||||
{
|
||||
if (!key.StartsWith("opsgenie.", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
!details.ContainsKey(key))
|
||||
{
|
||||
details[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return details.Count > 0 ? details : null;
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "OpsGenie",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
// OpsGenie API DTOs
|
||||
private sealed class OpsGenieCreateAlert
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("alias")]
|
||||
public string? Alias { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public string? Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("entity")]
|
||||
public string? Entity { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, string>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieAcknowledge
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieClose
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieNote
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public required string Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieResponse
|
||||
{
|
||||
[JsonPropertyName("result")]
|
||||
public string? Result { get; init; }
|
||||
|
||||
[JsonPropertyName("took")]
|
||||
public double Took { get; init; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for PagerDuty Events API v2.
|
||||
/// </summary>
|
||||
public sealed class PagerDutyChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private const string DefaultEventsApiUrl = "https://events.pagerduty.com/v2/enqueue";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<PagerDutyChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public PagerDutyChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PagerDutyChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!TryGetRoutingKey(context.Channel, out var routingKey))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Missing PagerDuty routing key.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"PagerDuty routing key is not configured.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var eventsUrl = GetEventsApiUrl(context.Channel);
|
||||
var eventAction = GetEventAction(context);
|
||||
var dedupKey = GetDedupKey(context);
|
||||
|
||||
var payload = BuildPayload(context, routingKey, eventAction, dedupKey);
|
||||
var payloadJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, eventsUrl);
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var pagerDutyResponse = JsonSerializer.Deserialize<PagerDutyResponse>(responseBody, JsonOptions);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["dedupKey"] = pagerDutyResponse?.DedupKey ?? dedupKey ?? string.Empty,
|
||||
["status"] = pagerDutyResponse?.Status ?? "success",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PagerDuty delivery {DeliveryId} succeeded with dedup_key={DedupKey} on attempt {Attempt}.",
|
||||
context.DeliveryId, pagerDutyResponse?.DedupKey, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: pagerDutyResponse?.DedupKey,
|
||||
message: $"PagerDuty event created: {pagerDutyResponse?.Status}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, "Rate limited by PagerDuty.", null, cancellationToken);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
"Rate limited by PagerDuty.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorMessage = $"PagerDuty returned {response.StatusCode}: {errorBody}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"PagerDuty delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"PagerDuty delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(ex, "PagerDuty delivery {DeliveryId} attempt {Attempt} failed.", context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug("PagerDuty delivery {DeliveryId} attempt {Attempt} timed out.", context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(lastException, "PagerDuty delivery {DeliveryId} exhausted all retries.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!TryGetRoutingKey(channel, out _))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("PagerDuty routing key is not configured.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
// PagerDuty doesn't have a health endpoint, just verify config
|
||||
return ChannelHealthCheckResult.Ok("PagerDuty channel configured.");
|
||||
}
|
||||
|
||||
private static bool TryGetRoutingKey(NotifyChannel channel, out string routingKey)
|
||||
{
|
||||
routingKey = string.Empty;
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("routingKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
routingKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("integrationKey", out key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
routingKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetEventsApiUrl(NotifyChannel channel)
|
||||
{
|
||||
if (channel.Config.Properties.TryGetValue("eventsApiUrl", out var url) && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
return DefaultEventsApiUrl;
|
||||
}
|
||||
|
||||
private static string GetEventAction(ChannelDispatchContext context)
|
||||
{
|
||||
// Check metadata for explicit action
|
||||
if (context.Metadata.TryGetValue("pagerduty.action", out var action) && !string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return action.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Check delivery metadata for incident state transitions
|
||||
if (context.Metadata.TryGetValue("incident.resolved", out var resolved) && resolved == "true")
|
||||
{
|
||||
return "resolve";
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.acknowledged", out var acked) && acked == "true")
|
||||
{
|
||||
return "acknowledge";
|
||||
}
|
||||
|
||||
return "trigger";
|
||||
}
|
||||
|
||||
private static string? GetDedupKey(ChannelDispatchContext context)
|
||||
{
|
||||
// Try explicit dedup key
|
||||
if (context.Metadata.TryGetValue("pagerduty.dedupKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
// Use incident ID if available
|
||||
if (context.Metadata.TryGetValue("incidentId", out var incidentId) && !string.IsNullOrWhiteSpace(incidentId))
|
||||
{
|
||||
return $"stellaops-{context.TenantId}-{incidentId}";
|
||||
}
|
||||
|
||||
// Use delivery ID as fallback
|
||||
return $"stellaops-{context.DeliveryId}";
|
||||
}
|
||||
|
||||
private PagerDutyEvent BuildPayload(
|
||||
ChannelDispatchContext context,
|
||||
string routingKey,
|
||||
string eventAction,
|
||||
string? dedupKey)
|
||||
{
|
||||
var severity = GetSeverity(context);
|
||||
var summary = context.Subject ?? "StellaOps Alert";
|
||||
var source = GetSource(context);
|
||||
var component = context.Metadata.GetValueOrDefault("component");
|
||||
var group = context.Metadata.GetValueOrDefault("group");
|
||||
var eventClass = context.Metadata.GetValueOrDefault("class");
|
||||
|
||||
return new PagerDutyEvent
|
||||
{
|
||||
RoutingKey = routingKey,
|
||||
EventAction = eventAction,
|
||||
DedupKey = dedupKey,
|
||||
Payload = new PagerDutyPayload
|
||||
{
|
||||
Summary = summary,
|
||||
Source = source,
|
||||
Severity = severity,
|
||||
Timestamp = context.Timestamp.ToString("O"),
|
||||
Component = component,
|
||||
Group = group,
|
||||
Class = eventClass,
|
||||
CustomDetails = BuildCustomDetails(context)
|
||||
},
|
||||
Links = BuildLinks(context),
|
||||
Client = "StellaOps",
|
||||
ClientUrl = context.Metadata.GetValueOrDefault("stellaops.url")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSeverity(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("severity", out var sev))
|
||||
{
|
||||
return sev.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "critical",
|
||||
"high" => "error",
|
||||
"medium" => "warning",
|
||||
"low" => "info",
|
||||
_ => "warning"
|
||||
};
|
||||
}
|
||||
return "warning";
|
||||
}
|
||||
|
||||
private static string GetSource(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("source", out var source) && !string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return source;
|
||||
}
|
||||
return $"stellaops-{context.TenantId}";
|
||||
}
|
||||
|
||||
private static Dictionary<string, object>? BuildCustomDetails(ChannelDispatchContext context)
|
||||
{
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["tenantId"] = context.TenantId,
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
// Add relevant metadata
|
||||
foreach (var (key, value) in context.Metadata)
|
||||
{
|
||||
if (!key.StartsWith("pagerduty.", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
details[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Include rendered body as description
|
||||
if (!string.IsNullOrWhiteSpace(context.RenderedBody))
|
||||
{
|
||||
details["description"] = context.RenderedBody;
|
||||
}
|
||||
|
||||
return details.Count > 0 ? details : null;
|
||||
}
|
||||
|
||||
private static List<PagerDutyLink>? BuildLinks(ChannelDispatchContext context)
|
||||
{
|
||||
var links = new List<PagerDutyLink>();
|
||||
|
||||
if (context.Metadata.TryGetValue("incidentUrl", out var incidentUrl) && !string.IsNullOrWhiteSpace(incidentUrl))
|
||||
{
|
||||
links.Add(new PagerDutyLink { Href = incidentUrl, Text = "View Incident" });
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("ackUrl", out var ackUrl) && !string.IsNullOrWhiteSpace(ackUrl))
|
||||
{
|
||||
links.Add(new PagerDutyLink { Href = ackUrl, Text = "Acknowledge" });
|
||||
}
|
||||
|
||||
return links.Count > 0 ? links : null;
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "PagerDuty",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
// PagerDuty API DTOs
|
||||
private sealed class PagerDutyEvent
|
||||
{
|
||||
[JsonPropertyName("routing_key")]
|
||||
public required string RoutingKey { get; init; }
|
||||
|
||||
[JsonPropertyName("event_action")]
|
||||
public required string EventAction { get; init; }
|
||||
|
||||
[JsonPropertyName("dedup_key")]
|
||||
public string? DedupKey { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public PagerDutyPayload? Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("links")]
|
||||
public List<PagerDutyLink>? Links { get; init; }
|
||||
|
||||
[JsonPropertyName("client")]
|
||||
public string? Client { get; init; }
|
||||
|
||||
[JsonPropertyName("client_url")]
|
||||
public string? ClientUrl { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyPayload
|
||||
{
|
||||
[JsonPropertyName("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public string? Component { get; init; }
|
||||
|
||||
[JsonPropertyName("group")]
|
||||
public string? Group { get; init; }
|
||||
|
||||
[JsonPropertyName("class")]
|
||||
public string? Class { get; init; }
|
||||
|
||||
[JsonPropertyName("custom_details")]
|
||||
public Dictionary<string, object>? CustomDetails { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyLink
|
||||
{
|
||||
[JsonPropertyName("href")]
|
||||
public required string Href { get; init; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
[JsonPropertyName("dedup_key")]
|
||||
public string? DedupKey { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for generic HTTP webhook dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<WebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid endpoint configuration.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Webhook endpoint is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(context, uri);
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, response, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery {DeliveryId} succeeded to {Endpoint} on attempt {Attempt} in {Duration}ms.",
|
||||
context.DeliveryId, endpoint, attempt, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {uri.Host} with status {response.StatusCode}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited by endpoint.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} throttled by {Endpoint}. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, endpoint, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {uri.Host}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"Webhook returned non-retryable status {response.StatusCode}.";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Webhook delivery {DeliveryId} exhausted all {MaxRetries} retries to {Endpoint}.",
|
||||
context.DeliveryId, maxRetries + 1, endpoint);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook endpoint is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"Endpoint responded with {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
$"Endpoint returned {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogDebug(ex, "Webhook health check failed for channel {ChannelId}.", channel.ChannelId);
|
||||
return ChannelHealthCheckResult.Unhealthy($"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(ChannelDispatchContext context, Uri uri)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(context.RenderedBody, Encoding.UTF8, "application/json");
|
||||
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
request.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
request.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
request.Headers.Add("X-StellaOps-Timestamp", context.Timestamp.ToString("O"));
|
||||
|
||||
if (_options.EnableHmacSigning && TryGetHmacSecret(context.Channel, out var secret))
|
||||
{
|
||||
var signature = ComputeHmacSignature(context.RenderedBody, secret);
|
||||
request.Headers.Add("X-StellaOps-Signature", $"sha256={signature}");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static bool TryGetHmacSecret(NotifyChannel channel, out string secret)
|
||||
{
|
||||
secret = string.Empty;
|
||||
if (channel.Config.Properties.TryGetValue("hmacSecret", out var s) && !string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
secret = s;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
HttpResponseMessage response,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = context.Channel.Config.Endpoint ?? string.Empty,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ICorrelationEngine"/>.
|
||||
/// Orchestrates key building, incident management, throttling, and quiet hours.
|
||||
/// </summary>
|
||||
public sealed class CorrelationEngine : ICorrelationEngine
|
||||
{
|
||||
private readonly ICorrelationKeyBuilderFactory _keyBuilderFactory;
|
||||
private readonly IIncidentManager _incidentManager;
|
||||
private readonly INotifyThrottler _throttler;
|
||||
private readonly IQuietHoursEvaluator _quietHoursEvaluator;
|
||||
private readonly CorrelationEngineOptions _options;
|
||||
private readonly ILogger<CorrelationEngine> _logger;
|
||||
|
||||
public CorrelationEngine(
|
||||
ICorrelationKeyBuilderFactory keyBuilderFactory,
|
||||
IIncidentManager incidentManager,
|
||||
INotifyThrottler throttler,
|
||||
IQuietHoursEvaluator quietHoursEvaluator,
|
||||
IOptions<CorrelationEngineOptions> options,
|
||||
ILogger<CorrelationEngine> logger)
|
||||
{
|
||||
_keyBuilderFactory = keyBuilderFactory ?? throw new ArgumentNullException(nameof(keyBuilderFactory));
|
||||
_incidentManager = incidentManager ?? throw new ArgumentNullException(nameof(incidentManager));
|
||||
_throttler = throttler ?? throw new ArgumentNullException(nameof(throttler));
|
||||
_quietHoursEvaluator = quietHoursEvaluator ?? throw new ArgumentNullException(nameof(quietHoursEvaluator));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CorrelationResult> CorrelateAsync(
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
|
||||
var tenantId = notifyEvent.Tenant;
|
||||
var eventKind = notifyEvent.Kind;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Correlating event {EventId} for tenant {TenantId}, kind {EventKind}.",
|
||||
notifyEvent.EventId, tenantId, eventKind);
|
||||
|
||||
// Step 1: Build correlation key
|
||||
var keyExpression = ResolveKeyExpression(eventKind);
|
||||
var keyBuilder = _keyBuilderFactory.GetBuilder(keyExpression.Type);
|
||||
var correlationKey = keyBuilder.BuildKey(notifyEvent, keyExpression);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Built correlation key {Key} using {Builder} for event {EventId}.",
|
||||
correlationKey, keyBuilder.Name, notifyEvent.EventId);
|
||||
|
||||
// Step 2: Get or create incident
|
||||
var incident = await _incidentManager.GetOrCreateIncidentAsync(
|
||||
tenantId,
|
||||
correlationKey,
|
||||
eventKind,
|
||||
BuildIncidentTitle(notifyEvent),
|
||||
cancellationToken);
|
||||
|
||||
var isNewIncident = incident.EventCount == 0;
|
||||
|
||||
// Step 3: Record the event against the incident
|
||||
incident = await _incidentManager.RecordEventAsync(
|
||||
tenantId,
|
||||
incident.IncidentId,
|
||||
notifyEvent.EventId.ToString(),
|
||||
cancellationToken);
|
||||
|
||||
// Step 4: Record event for throttling
|
||||
await _throttler.RecordEventAsync(tenantId, correlationKey, cancellationToken);
|
||||
|
||||
// Step 5: Check suppression (quiet hours / maintenance)
|
||||
var suppression = await CheckSuppressionAsync(tenantId, eventKind, cancellationToken);
|
||||
if (suppression.IsSuppressed)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Event {EventId} suppressed: {Reason}.",
|
||||
notifyEvent.EventId, suppression.Reason);
|
||||
|
||||
return BuildResult(incident, correlationKey, isNewIncident, shouldNotify: false, suppression.Reason);
|
||||
}
|
||||
|
||||
// Step 6: Check throttling
|
||||
var throttleWindow = ResolveThrottleWindow(eventKind);
|
||||
var throttle = await CheckThrottleAsync(tenantId, correlationKey, throttleWindow, cancellationToken);
|
||||
if (throttle.IsThrottled)
|
||||
{
|
||||
var reason = $"Throttled: {throttle.RecentEventCount} events in window";
|
||||
_logger.LogInformation(
|
||||
"Event {EventId} throttled: {Count} events.",
|
||||
notifyEvent.EventId, throttle.RecentEventCount);
|
||||
|
||||
return BuildResult(incident, correlationKey, isNewIncident, shouldNotify: false, reason);
|
||||
}
|
||||
|
||||
// Step 7: Apply notification policy
|
||||
var shouldNotify = EvaluateNotificationPolicy(incident, isNewIncident, notifyEvent);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Correlated event {EventId} to incident {IncidentId}, new={IsNew}, notify={Notify}.",
|
||||
notifyEvent.EventId, incident.IncidentId, isNewIncident, shouldNotify);
|
||||
|
||||
return BuildResult(incident, correlationKey, isNewIncident, shouldNotify, suppressionReason: null);
|
||||
}
|
||||
|
||||
public Task<SuppressionCheckResult> CheckSuppressionAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _quietHoursEvaluator.EvaluateAsync(tenantId, eventKind, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ThrottleCheckResult> CheckThrottleAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
TimeSpan? throttleWindow,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.ThrottlingEnabled)
|
||||
{
|
||||
return ThrottleCheckResult.NotThrottled();
|
||||
}
|
||||
|
||||
return await _throttler.CheckAsync(
|
||||
tenantId,
|
||||
correlationKey,
|
||||
throttleWindow ?? _options.DefaultThrottleWindow,
|
||||
_options.DefaultThrottleMaxEvents,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private CorrelationKeyExpression ResolveKeyExpression(string eventKind)
|
||||
{
|
||||
// Check for event-kind-specific expression
|
||||
if (_options.KeyExpressions.TryGetValue(eventKind, out var expression))
|
||||
{
|
||||
return expression;
|
||||
}
|
||||
|
||||
// Check for prefix match
|
||||
foreach (var (pattern, expr) in _options.KeyExpressions)
|
||||
{
|
||||
if (pattern.EndsWith('*') &&
|
||||
eventKind.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return expr;
|
||||
}
|
||||
}
|
||||
|
||||
// Use default expression
|
||||
return _options.DefaultKeyExpression ?? CorrelationKeyExpression.Default;
|
||||
}
|
||||
|
||||
private TimeSpan? ResolveThrottleWindow(string eventKind)
|
||||
{
|
||||
// Check for event-kind-specific throttle window
|
||||
if (_options.ThrottleWindows.TryGetValue(eventKind, out var window))
|
||||
{
|
||||
return window;
|
||||
}
|
||||
|
||||
// Check for prefix match
|
||||
foreach (var (pattern, w) in _options.ThrottleWindows)
|
||||
{
|
||||
if (pattern.EndsWith('*') &&
|
||||
eventKind.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
return _options.DefaultThrottleWindow;
|
||||
}
|
||||
|
||||
private static string BuildIncidentTitle(NotifyEvent notifyEvent)
|
||||
{
|
||||
// Build a reasonable default title from the event
|
||||
var kind = notifyEvent.Kind;
|
||||
|
||||
// Try to extract a summary from common payload fields
|
||||
if (notifyEvent.Payload is System.Text.Json.Nodes.JsonObject payload)
|
||||
{
|
||||
if (payload.TryGetPropertyValue("title", out var titleNode) &&
|
||||
titleNode?.GetValue<string>() is { Length: > 0 } title)
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
if (payload.TryGetPropertyValue("summary", out var summaryNode) &&
|
||||
summaryNode?.GetValue<string>() is { Length: > 0 } summary)
|
||||
{
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (payload.TryGetPropertyValue("message", out var msgNode) &&
|
||||
msgNode?.GetValue<string>() is { Length: > 0 } message)
|
||||
{
|
||||
return message.Length > 100 ? message[..100] + "..." : message;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to event kind
|
||||
return $"Incident: {kind}";
|
||||
}
|
||||
|
||||
private bool EvaluateNotificationPolicy(IncidentState incident, bool isNewIncident, NotifyEvent notifyEvent)
|
||||
{
|
||||
// Always notify for new incidents
|
||||
if (isNewIncident)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check notification policy for existing incidents
|
||||
return _options.NotificationPolicy switch
|
||||
{
|
||||
NotificationPolicy.FirstOnly => false,
|
||||
NotificationPolicy.EveryEvent => true,
|
||||
NotificationPolicy.OnEscalation => CheckEscalation(incident, notifyEvent),
|
||||
NotificationPolicy.Periodic => CheckPeriodicNotification(incident),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CheckEscalation(IncidentState incident, NotifyEvent notifyEvent)
|
||||
{
|
||||
// Check if this event represents an escalation (e.g., severity increase)
|
||||
if (notifyEvent.Payload is not System.Text.Json.Nodes.JsonObject payload)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look for severity/priority fields that indicate escalation
|
||||
if (payload.TryGetPropertyValue("severity", out var severityNode))
|
||||
{
|
||||
var severity = severityNode?.GetValue<string>()?.ToUpperInvariant();
|
||||
if (severity is "CRITICAL" or "HIGH" or "P1" or "SEV1")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify at certain event count thresholds
|
||||
return incident.EventCount switch
|
||||
{
|
||||
5 => true,
|
||||
10 => true,
|
||||
25 => true,
|
||||
50 => true,
|
||||
100 => true,
|
||||
_ when incident.EventCount % 100 == 0 => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CheckPeriodicNotification(IncidentState incident)
|
||||
{
|
||||
// Notify at configured intervals
|
||||
var interval = _options.PeriodicNotificationInterval;
|
||||
return incident.EventCount % interval == 0;
|
||||
}
|
||||
|
||||
private static CorrelationResult BuildResult(
|
||||
IncidentState incident,
|
||||
string correlationKey,
|
||||
bool isNewIncident,
|
||||
bool shouldNotify,
|
||||
string? suppressionReason)
|
||||
{
|
||||
return isNewIncident
|
||||
? CorrelationResult.NewIncident(incident.IncidentId, correlationKey, shouldNotify, suppressionReason)
|
||||
: CorrelationResult.ExistingIncident(
|
||||
incident.IncidentId,
|
||||
correlationKey,
|
||||
incident.EventCount,
|
||||
shouldNotify,
|
||||
suppressionReason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification policy for recurring events in the same incident.
|
||||
/// </summary>
|
||||
public enum NotificationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Only notify on the first event (new incident).
|
||||
/// </summary>
|
||||
FirstOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Notify on every event (subject to throttling).
|
||||
/// </summary>
|
||||
EveryEvent,
|
||||
|
||||
/// <summary>
|
||||
/// Notify on escalation (severity increase or event count thresholds).
|
||||
/// </summary>
|
||||
OnEscalation,
|
||||
|
||||
/// <summary>
|
||||
/// Notify at periodic intervals (every N events).
|
||||
/// </summary>
|
||||
Periodic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the correlation engine.
|
||||
/// </summary>
|
||||
public sealed class CorrelationEngineOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:Correlation";
|
||||
|
||||
/// <summary>
|
||||
/// Whether correlation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default correlation key expression.
|
||||
/// </summary>
|
||||
public CorrelationKeyExpression? DefaultKeyExpression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event-kind-specific key expressions. Supports wildcard suffix (e.g., "security.*").
|
||||
/// </summary>
|
||||
public Dictionary<string, CorrelationKeyExpression> KeyExpressions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled.
|
||||
/// </summary>
|
||||
public bool ThrottlingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultThrottleWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum events before throttling.
|
||||
/// </summary>
|
||||
public int DefaultThrottleMaxEvents { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Event-kind-specific throttle windows.
|
||||
/// </summary>
|
||||
public Dictionary<string, TimeSpan> ThrottleWindows { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Notification policy for recurring events.
|
||||
/// </summary>
|
||||
public NotificationPolicy NotificationPolicy { get; set; } = NotificationPolicy.OnEscalation;
|
||||
|
||||
/// <summary>
|
||||
/// Interval for periodic notifications (used when policy is Periodic).
|
||||
/// </summary>
|
||||
public int PeriodicNotificationInterval { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering correlation services.
|
||||
/// </summary>
|
||||
public static class CorrelationServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds correlation engine and related services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCorrelationServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register options
|
||||
services.Configure<CorrelationEngineOptions>(
|
||||
configuration.GetSection(CorrelationEngineOptions.SectionName));
|
||||
services.Configure<ThrottlerOptions>(
|
||||
configuration.GetSection(ThrottlerOptions.SectionName));
|
||||
services.Configure<QuietHoursOptions>(
|
||||
configuration.GetSection(QuietHoursOptions.SectionName));
|
||||
services.Configure<IncidentManagerOptions>(
|
||||
configuration.GetSection(IncidentManagerOptions.SectionName));
|
||||
services.Configure<SuppressionAuditOptions>(
|
||||
configuration.GetSection(SuppressionAuditOptions.SectionName));
|
||||
services.Configure<OperatorOverrideOptions>(
|
||||
configuration.GetSection(OperatorOverrideOptions.SectionName));
|
||||
|
||||
// Register key builders
|
||||
services.AddSingleton<ICorrelationKeyBuilder, CompositeCorrelationKeyBuilder>();
|
||||
services.AddSingleton<ICorrelationKeyBuilder, TemplateCorrelationKeyBuilder>();
|
||||
services.AddSingleton<ICorrelationKeyBuilderFactory, CorrelationKeyBuilderFactory>();
|
||||
|
||||
// Register audit logging (must be registered before other services that use it)
|
||||
services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
||||
|
||||
// Register core services (in-memory implementations)
|
||||
services.AddSingleton<INotifyThrottler, InMemoryNotifyThrottler>();
|
||||
services.AddSingleton<IQuietHoursEvaluator, QuietHoursEvaluator>();
|
||||
services.AddSingleton<IIncidentManager, InMemoryIncidentManager>();
|
||||
|
||||
// Register quiet hour calendar service
|
||||
services.AddSingleton<IQuietHourCalendarService, InMemoryQuietHourCalendarService>();
|
||||
|
||||
// Register throttle configuration service
|
||||
services.AddSingleton<IThrottleConfigService, InMemoryThrottleConfigService>();
|
||||
|
||||
// Register operator override service
|
||||
services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
||||
|
||||
// Register correlation engine
|
||||
services.AddSingleton<ICorrelationEngine, CorrelationEngine>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds correlation services with custom implementations.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCorrelationServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<CorrelationServiceBuilder> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Register options
|
||||
services.Configure<CorrelationEngineOptions>(
|
||||
configuration.GetSection(CorrelationEngineOptions.SectionName));
|
||||
services.Configure<ThrottlerOptions>(
|
||||
configuration.GetSection(ThrottlerOptions.SectionName));
|
||||
services.Configure<QuietHoursOptions>(
|
||||
configuration.GetSection(QuietHoursOptions.SectionName));
|
||||
services.Configure<IncidentManagerOptions>(
|
||||
configuration.GetSection(IncidentManagerOptions.SectionName));
|
||||
services.Configure<SuppressionAuditOptions>(
|
||||
configuration.GetSection(SuppressionAuditOptions.SectionName));
|
||||
services.Configure<OperatorOverrideOptions>(
|
||||
configuration.GetSection(OperatorOverrideOptions.SectionName));
|
||||
|
||||
// Register key builders
|
||||
services.AddSingleton<ICorrelationKeyBuilder, CompositeCorrelationKeyBuilder>();
|
||||
services.AddSingleton<ICorrelationKeyBuilder, TemplateCorrelationKeyBuilder>();
|
||||
services.AddSingleton<ICorrelationKeyBuilderFactory, CorrelationKeyBuilderFactory>();
|
||||
|
||||
// Apply custom configuration
|
||||
var builder = new CorrelationServiceBuilder(services);
|
||||
configure(builder);
|
||||
|
||||
// Register defaults for any services not configured
|
||||
services.TryAddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
||||
services.TryAddSingleton<INotifyThrottler, InMemoryNotifyThrottler>();
|
||||
services.TryAddSingleton<IQuietHoursEvaluator, QuietHoursEvaluator>();
|
||||
services.TryAddSingleton<IIncidentManager, InMemoryIncidentManager>();
|
||||
services.TryAddSingleton<IQuietHourCalendarService, InMemoryQuietHourCalendarService>();
|
||||
services.TryAddSingleton<IThrottleConfigService, InMemoryThrottleConfigService>();
|
||||
services.TryAddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
||||
services.TryAddSingleton<ICorrelationEngine, CorrelationEngine>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void TryAddSingleton<TService, TImplementation>(this IServiceCollection services)
|
||||
where TService : class
|
||||
where TImplementation : class, TService
|
||||
{
|
||||
if (!services.Any(d => d.ServiceType == typeof(TService)))
|
||||
{
|
||||
services.AddSingleton<TService, TImplementation>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for customizing correlation service registrations.
|
||||
/// </summary>
|
||||
public sealed class CorrelationServiceBuilder
|
||||
{
|
||||
private readonly IServiceCollection _services;
|
||||
|
||||
internal CorrelationServiceBuilder(IServiceCollection services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom throttler implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseThrottler<TThrottler>()
|
||||
where TThrottler : class, INotifyThrottler
|
||||
{
|
||||
_services.AddSingleton<INotifyThrottler, TThrottler>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom quiet hours evaluator implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseQuietHoursEvaluator<TEvaluator>()
|
||||
where TEvaluator : class, IQuietHoursEvaluator
|
||||
{
|
||||
_services.AddSingleton<IQuietHoursEvaluator, TEvaluator>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom incident manager implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseIncidentManager<TManager>()
|
||||
where TManager : class, IIncidentManager
|
||||
{
|
||||
_services.AddSingleton<IIncidentManager, TManager>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom correlation engine implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseCorrelationEngine<TEngine>()
|
||||
where TEngine : class, ICorrelationEngine
|
||||
{
|
||||
_services.AddSingleton<ICorrelationEngine, TEngine>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an additional correlation key builder.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder AddKeyBuilder<TBuilder>()
|
||||
where TBuilder : class, ICorrelationKeyBuilder
|
||||
{
|
||||
_services.AddSingleton<ICorrelationKeyBuilder, TBuilder>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom suppression audit logger implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseAuditLogger<TAuditLogger>()
|
||||
where TAuditLogger : class, ISuppressionAuditLogger
|
||||
{
|
||||
_services.AddSingleton<ISuppressionAuditLogger, TAuditLogger>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom quiet hour calendar service implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseQuietHourCalendarService<TService>()
|
||||
where TService : class, IQuietHourCalendarService
|
||||
{
|
||||
_services.AddSingleton<IQuietHourCalendarService, TService>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom throttle config service implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseThrottleConfigService<TService>()
|
||||
where TService : class, IThrottleConfigService
|
||||
{
|
||||
_services.AddSingleton<IThrottleConfigService, TService>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom operator override service implementation.
|
||||
/// </summary>
|
||||
public CorrelationServiceBuilder UseOperatorOverrideService<TService>()
|
||||
where TService : class, IOperatorOverrideService
|
||||
{
|
||||
_services.AddSingleton<IOperatorOverrideService, TService>();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Engine for correlating notification events into incidents with throttling and quiet hours support.
|
||||
/// </summary>
|
||||
public interface ICorrelationEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes an event and returns correlation result with incident assignment.
|
||||
/// </summary>
|
||||
Task<CorrelationResult> CorrelateAsync(
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if notifications are currently suppressed (quiet hours/maintenance).
|
||||
/// </summary>
|
||||
Task<SuppressionCheckResult> CheckSuppressionAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an event should be throttled based on recent activity.
|
||||
/// </summary>
|
||||
Task<ThrottleCheckResult> CheckThrottleAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
TimeSpan? throttleWindow,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of event correlation.
|
||||
/// </summary>
|
||||
public sealed record CorrelationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the event was correlated to an existing or new incident.
|
||||
/// </summary>
|
||||
public required bool Correlated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The incident ID this event belongs to.
|
||||
/// </summary>
|
||||
public required string IncidentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a new incident.
|
||||
/// </summary>
|
||||
public required bool IsNewIncident { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The correlation key used.
|
||||
/// </summary>
|
||||
public required string CorrelationKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of events in this incident.
|
||||
/// </summary>
|
||||
public required int EventCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether notifications should be sent (not throttled/suppressed).
|
||||
/// </summary>
|
||||
public required bool ShouldNotify { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason if notification is suppressed.
|
||||
/// </summary>
|
||||
public string? SuppressionReason { get; init; }
|
||||
|
||||
public static CorrelationResult NewIncident(string incidentId, string correlationKey, bool shouldNotify, string? suppressionReason = null) =>
|
||||
new()
|
||||
{
|
||||
Correlated = true,
|
||||
IncidentId = incidentId,
|
||||
IsNewIncident = true,
|
||||
CorrelationKey = correlationKey,
|
||||
EventCount = 1,
|
||||
ShouldNotify = shouldNotify,
|
||||
SuppressionReason = suppressionReason
|
||||
};
|
||||
|
||||
public static CorrelationResult ExistingIncident(string incidentId, string correlationKey, int eventCount, bool shouldNotify, string? suppressionReason = null) =>
|
||||
new()
|
||||
{
|
||||
Correlated = true,
|
||||
IncidentId = incidentId,
|
||||
IsNewIncident = false,
|
||||
CorrelationKey = correlationKey,
|
||||
EventCount = eventCount,
|
||||
ShouldNotify = shouldNotify,
|
||||
SuppressionReason = suppressionReason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of suppression check (quiet hours/maintenance).
|
||||
/// </summary>
|
||||
public sealed record SuppressionCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether notifications are currently suppressed.
|
||||
/// </summary>
|
||||
public required bool IsSuppressed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for suppression.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When suppression ends (if known).
|
||||
/// </summary>
|
||||
public DateTimeOffset? SuppressedUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of suppression (quiet_hours, maintenance, manual).
|
||||
/// </summary>
|
||||
public string? SuppressionType { get; init; }
|
||||
|
||||
public static SuppressionCheckResult NotSuppressed() => new() { IsSuppressed = false };
|
||||
|
||||
public static SuppressionCheckResult Suppressed(string reason, string type, DateTimeOffset? until = null) =>
|
||||
new()
|
||||
{
|
||||
IsSuppressed = true,
|
||||
Reason = reason,
|
||||
SuppressionType = type,
|
||||
SuppressedUntil = until
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of throttle check.
|
||||
/// </summary>
|
||||
public sealed record ThrottleCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the event should be throttled.
|
||||
/// </summary>
|
||||
public required bool IsThrottled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of recent events in the throttle window.
|
||||
/// </summary>
|
||||
public int RecentEventCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time until throttle resets.
|
||||
/// </summary>
|
||||
public TimeSpan? ThrottleResetIn { get; init; }
|
||||
|
||||
public static ThrottleCheckResult NotThrottled(int recentCount = 0) =>
|
||||
new() { IsThrottled = false, RecentEventCount = recentCount };
|
||||
|
||||
public static ThrottleCheckResult Throttled(int recentCount, TimeSpan? resetIn = null) =>
|
||||
new() { IsThrottled = true, RecentEventCount = recentCount, ThrottleResetIn = resetIn };
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Builds correlation keys from events for grouping into incidents.
|
||||
/// </summary>
|
||||
public interface ICorrelationKeyBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique name of this key builder.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a correlation key from the event.
|
||||
/// </summary>
|
||||
string BuildKey(NotifyEvent notifyEvent, CorrelationKeyExpression expression);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this builder can handle the given expression type.
|
||||
/// </summary>
|
||||
bool CanHandle(string expressionType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expression configuration for correlation key building.
|
||||
/// </summary>
|
||||
public sealed record CorrelationKeyExpression
|
||||
{
|
||||
/// <summary>
|
||||
/// Expression type (e.g., "composite", "jsonpath", "template").
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fields to include in the key (for composite type).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Fields { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSONPath expression (for jsonpath type).
|
||||
/// </summary>
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Template string (for template type).
|
||||
/// </summary>
|
||||
public string? Template { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include tenant in the key (default: true).
|
||||
/// </summary>
|
||||
public bool IncludeTenant { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include event kind in the key (default: true).
|
||||
/// </summary>
|
||||
public bool IncludeEventKind { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default expression: correlate by tenant + event kind.
|
||||
/// </summary>
|
||||
public static CorrelationKeyExpression Default => new()
|
||||
{
|
||||
Type = "composite",
|
||||
Fields = [],
|
||||
IncludeTenant = true,
|
||||
IncludeEventKind = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default correlation key builder using composite fields.
|
||||
/// </summary>
|
||||
public sealed class CompositeCorrelationKeyBuilder : ICorrelationKeyBuilder
|
||||
{
|
||||
public string Name => "composite";
|
||||
|
||||
public bool CanHandle(string expressionType) =>
|
||||
expressionType.Equals("composite", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string BuildKey(NotifyEvent notifyEvent, CorrelationKeyExpression expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
var parts = new List<string>();
|
||||
|
||||
if (expression.IncludeTenant)
|
||||
{
|
||||
parts.Add($"tenant:{notifyEvent.Tenant}");
|
||||
}
|
||||
|
||||
if (expression.IncludeEventKind)
|
||||
{
|
||||
parts.Add($"kind:{notifyEvent.Kind}");
|
||||
}
|
||||
|
||||
if (expression.Fields is { Count: > 0 } && notifyEvent.Payload is JsonObject payload)
|
||||
{
|
||||
foreach (var field in expression.Fields)
|
||||
{
|
||||
var value = ExtractField(payload, field);
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
parts.Add($"{field}:{value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var keyString = string.Join("|", parts);
|
||||
return ComputeKeyHash(keyString);
|
||||
}
|
||||
|
||||
private static string? ExtractField(JsonObject payload, string field)
|
||||
{
|
||||
var parts = field.Split('.');
|
||||
JsonNode? current = payload;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(part, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current?.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeKeyHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template-based correlation key builder.
|
||||
/// </summary>
|
||||
public sealed class TemplateCorrelationKeyBuilder : ICorrelationKeyBuilder
|
||||
{
|
||||
public string Name => "template";
|
||||
|
||||
public bool CanHandle(string expressionType) =>
|
||||
expressionType.Equals("template", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string BuildKey(NotifyEvent notifyEvent, CorrelationKeyExpression expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression.Template))
|
||||
{
|
||||
throw new ArgumentException("Template expression requires a template string.", nameof(expression));
|
||||
}
|
||||
|
||||
var result = expression.Template;
|
||||
|
||||
// Replace built-in variables
|
||||
result = result.Replace("{{tenant}}", notifyEvent.Tenant);
|
||||
result = result.Replace("{{kind}}", notifyEvent.Kind);
|
||||
result = result.Replace("{{eventId}}", notifyEvent.EventId.ToString());
|
||||
|
||||
// Replace payload variables
|
||||
if (notifyEvent.Payload is JsonObject payload)
|
||||
{
|
||||
foreach (var prop in payload)
|
||||
{
|
||||
result = result.Replace($"{{{{{prop.Key}}}}}", prop.Value?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Replace attribute variables
|
||||
foreach (var (key, value) in notifyEvent.Attributes)
|
||||
{
|
||||
result = result.Replace($"{{{{attr.{key}}}}}", value);
|
||||
}
|
||||
|
||||
var keyString = expression.IncludeTenant
|
||||
? $"{notifyEvent.Tenant}|{result}"
|
||||
: result;
|
||||
|
||||
return ComputeKeyHash(keyString);
|
||||
}
|
||||
|
||||
private static string ComputeKeyHash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for resolving correlation key builders.
|
||||
/// </summary>
|
||||
public interface ICorrelationKeyBuilderFactory
|
||||
{
|
||||
ICorrelationKeyBuilder GetBuilder(string expressionType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ICorrelationKeyBuilderFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class CorrelationKeyBuilderFactory : ICorrelationKeyBuilderFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ICorrelationKeyBuilder> _builders;
|
||||
private readonly ICorrelationKeyBuilder _defaultBuilder;
|
||||
|
||||
public CorrelationKeyBuilderFactory(IEnumerable<ICorrelationKeyBuilder> builders)
|
||||
{
|
||||
var builderList = builders.ToList();
|
||||
_builders = builderList.ToDictionary(b => b.Name, b => b, StringComparer.OrdinalIgnoreCase);
|
||||
_defaultBuilder = builderList.FirstOrDefault(b => b.Name == "composite")
|
||||
?? new CompositeCorrelationKeyBuilder();
|
||||
}
|
||||
|
||||
public ICorrelationKeyBuilder GetBuilder(string expressionType)
|
||||
{
|
||||
if (_builders.TryGetValue(expressionType, out var builder))
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
foreach (var b in _builders.Values)
|
||||
{
|
||||
if (b.CanHandle(expressionType))
|
||||
{
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
return _defaultBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing operator overrides that bypass quiet hours and throttling.
|
||||
/// </summary>
|
||||
public interface IOperatorOverrideService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new operator override.
|
||||
/// </summary>
|
||||
Task<OperatorOverride> CreateOverrideAsync(
|
||||
string tenantId,
|
||||
OperatorOverrideCreate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an active override by ID.
|
||||
/// </summary>
|
||||
Task<OperatorOverride?> GetOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists active overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OperatorOverride>> ListActiveOverridesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an active override.
|
||||
/// </summary>
|
||||
Task<bool> RevokeOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an override applies to the given event.
|
||||
/// </summary>
|
||||
Task<OverrideCheckResult> CheckOverrideAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
string? correlationKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records that an override was used to bypass suppression.
|
||||
/// </summary>
|
||||
Task RecordOverrideUsageAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An operator override that bypasses quiet hours and/or throttling.
|
||||
/// </summary>
|
||||
public sealed record OperatorOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this override.
|
||||
/// </summary>
|
||||
public required string OverrideId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant this override belongs to.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What this override bypasses.
|
||||
/// </summary>
|
||||
public required OverrideType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Why this override was created.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the override becomes active.
|
||||
/// </summary>
|
||||
public required DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the override expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds this override applies to (empty = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EventKinds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Correlation keys this override applies to (empty = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CorrelationKeys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum times this override can be used (null = unlimited).
|
||||
/// </summary>
|
||||
public int? MaxUsageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times this override has been used.
|
||||
/// </summary>
|
||||
public int UsageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created this override.
|
||||
/// </summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the override was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public required OverrideStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who revoked the override (if revoked).
|
||||
/// </summary>
|
||||
public string? RevokedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the override was revoked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
public string? RevocationReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of suppression that can be overridden.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum OverrideType
|
||||
{
|
||||
/// <summary>Bypass quiet hours.</summary>
|
||||
QuietHours = 1,
|
||||
|
||||
/// <summary>Bypass throttling.</summary>
|
||||
Throttle = 2,
|
||||
|
||||
/// <summary>Bypass maintenance windows.</summary>
|
||||
Maintenance = 4,
|
||||
|
||||
/// <summary>Bypass all suppression types.</summary>
|
||||
All = QuietHours | Throttle | Maintenance
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an operator override.
|
||||
/// </summary>
|
||||
public enum OverrideStatus
|
||||
{
|
||||
/// <summary>Override is active.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Override has expired.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Override was manually revoked.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Override has reached max usage count.</summary>
|
||||
Exhausted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed record OperatorOverrideCreate
|
||||
{
|
||||
/// <summary>
|
||||
/// What to override.
|
||||
/// </summary>
|
||||
public required OverrideType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Why this override is needed.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How long the override should last.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: When the override should become active (default: now).
|
||||
/// </summary>
|
||||
public DateTimeOffset? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds to override (empty = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? EventKinds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation keys to override (empty = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? CorrelationKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum times this override can be used.
|
||||
/// </summary>
|
||||
public int? MaxUsageCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of checking for applicable overrides.
|
||||
/// </summary>
|
||||
public sealed record OverrideCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether an override applies.
|
||||
/// </summary>
|
||||
public bool HasOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that applies (if any).
|
||||
/// </summary>
|
||||
public OperatorOverride? Override { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Types of suppression being bypassed.
|
||||
/// </summary>
|
||||
public OverrideType BypassedTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no override applies.
|
||||
/// </summary>
|
||||
public static OverrideCheckResult NoOverride() => new() { HasOverride = false };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating an override applies.
|
||||
/// </summary>
|
||||
public static OverrideCheckResult WithOverride(OperatorOverride @override) => new()
|
||||
{
|
||||
HasOverride = true,
|
||||
Override = @override,
|
||||
BypassedTypes = @override.Type
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing quiet hour calendars per tenant.
|
||||
/// </summary>
|
||||
public interface IQuietHourCalendarService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all quiet hour calendars for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuietHourCalendar>> ListCalendarsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific calendar by ID.
|
||||
/// </summary>
|
||||
Task<QuietHourCalendar?> GetCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new quiet hour calendar.
|
||||
/// </summary>
|
||||
Task<QuietHourCalendar> CreateCalendarAsync(
|
||||
string tenantId,
|
||||
QuietHourCalendarCreate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing quiet hour calendar.
|
||||
/// </summary>
|
||||
Task<QuietHourCalendar?> UpdateCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
QuietHourCalendarUpdate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiet hour calendar.
|
||||
/// </summary>
|
||||
Task<bool> DeleteCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates all calendars to determine if notifications should be suppressed.
|
||||
/// </summary>
|
||||
Task<CalendarEvaluationResult> EvaluateCalendarsAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
IReadOnlyList<string>? scopes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A quiet hour calendar with named schedules.
|
||||
/// </summary>
|
||||
public sealed record QuietHourCalendar
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the calendar.
|
||||
/// </summary>
|
||||
public required string CalendarId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant this calendar belongs to.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for the calendar.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of when this calendar applies.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this calendar is active.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority when multiple calendars match (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Scopes this calendar applies to (e.g., teams, components).
|
||||
/// Empty means applies to all scopes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Scopes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds this calendar applies to.
|
||||
/// Empty means applies to all event kinds.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EventKinds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds excluded from this calendar (always notify).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExcludedEventKinds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hour schedules within this calendar.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CalendarSchedule> Schedules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for all schedules (IANA format).
|
||||
/// </summary>
|
||||
public string Timezone { get; init; } = "UTC";
|
||||
|
||||
/// <summary>
|
||||
/// Who created this calendar.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the calendar was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who last updated this calendar.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the calendar was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A schedule within a quiet hour calendar.
|
||||
/// </summary>
|
||||
public sealed record CalendarSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of this schedule (e.g., "Weeknight quiet hours").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this schedule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Start time in 24h format (e.g., "22:00").
|
||||
/// </summary>
|
||||
public required string StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End time in 24h format (e.g., "08:00").
|
||||
/// </summary>
|
||||
public required string EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days of week when this schedule applies (0=Sunday, 6=Saturday).
|
||||
/// Empty means all days.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> DaysOfWeek { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Specific dates when this schedule applies (overrides DaysOfWeek).
|
||||
/// </summary>
|
||||
public IReadOnlyList<DateOnly> SpecificDates { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Dates to exclude from this schedule.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DateOnly> ExcludedDates { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a quiet hour calendar.
|
||||
/// </summary>
|
||||
public sealed record QuietHourCalendarCreate
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public int Priority { get; init; } = 100;
|
||||
public IReadOnlyList<string>? Scopes { get; init; }
|
||||
public IReadOnlyList<string>? EventKinds { get; init; }
|
||||
public IReadOnlyList<string>? ExcludedEventKinds { get; init; }
|
||||
public IReadOnlyList<CalendarSchedule>? Schedules { get; init; }
|
||||
public string? Timezone { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a quiet hour calendar.
|
||||
/// </summary>
|
||||
public sealed record QuietHourCalendarUpdate
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public int? Priority { get; init; }
|
||||
public IReadOnlyList<string>? Scopes { get; init; }
|
||||
public IReadOnlyList<string>? EventKinds { get; init; }
|
||||
public IReadOnlyList<string>? ExcludedEventKinds { get; init; }
|
||||
public IReadOnlyList<CalendarSchedule>? Schedules { get; init; }
|
||||
public string? Timezone { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating quiet hour calendars.
|
||||
/// </summary>
|
||||
public sealed record CalendarEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether notifications should be suppressed.
|
||||
/// </summary>
|
||||
public bool IsSuppressed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for suppression (if applicable).
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calendar that caused suppression (if applicable).
|
||||
/// </summary>
|
||||
public string? CalendarId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calendar name.
|
||||
/// </summary>
|
||||
public string? CalendarName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule name within the calendar.
|
||||
/// </summary>
|
||||
public string? ScheduleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When suppression ends.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SuppressedUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a not-suppressed result.
|
||||
/// </summary>
|
||||
public static CalendarEvaluationResult NotSuppressed() => new() { IsSuppressed = false };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a suppressed result.
|
||||
/// </summary>
|
||||
public static CalendarEvaluationResult Suppressed(
|
||||
string calendarId,
|
||||
string calendarName,
|
||||
string scheduleName,
|
||||
string reason,
|
||||
DateTimeOffset? until) => new()
|
||||
{
|
||||
IsSuppressed = true,
|
||||
CalendarId = calendarId,
|
||||
CalendarName = calendarName,
|
||||
ScheduleName = scheduleName,
|
||||
Reason = reason,
|
||||
SuppressedUntil = until
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for audit logging suppression configuration changes.
|
||||
/// </summary>
|
||||
public interface ISuppressionAuditLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs an audit entry.
|
||||
/// </summary>
|
||||
Task LogAsync(SuppressionAuditEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries audit entries for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SuppressionAuditEntry>> QueryAsync(
|
||||
SuppressionAuditQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry for suppression configuration changes.
|
||||
/// </summary>
|
||||
public sealed record SuppressionAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this entry.
|
||||
/// </summary>
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who performed the action.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action performed.
|
||||
/// </summary>
|
||||
public required SuppressionAuditAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of resource affected.
|
||||
/// </summary>
|
||||
public required string ResourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier of the affected resource.
|
||||
/// </summary>
|
||||
public required string ResourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the change.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Client IP address (if available).
|
||||
/// </summary>
|
||||
public string? ClientIp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent (if available).
|
||||
/// </summary>
|
||||
public string? UserAgent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for request tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of suppression audit actions.
|
||||
/// </summary>
|
||||
public enum SuppressionAuditAction
|
||||
{
|
||||
/// <summary>Calendar created.</summary>
|
||||
CalendarCreated,
|
||||
|
||||
/// <summary>Calendar updated.</summary>
|
||||
CalendarUpdated,
|
||||
|
||||
/// <summary>Calendar deleted.</summary>
|
||||
CalendarDeleted,
|
||||
|
||||
/// <summary>Throttle configuration updated.</summary>
|
||||
ThrottleConfigUpdated,
|
||||
|
||||
/// <summary>Throttle configuration deleted.</summary>
|
||||
ThrottleConfigDeleted,
|
||||
|
||||
/// <summary>Maintenance window created.</summary>
|
||||
MaintenanceWindowCreated,
|
||||
|
||||
/// <summary>Maintenance window updated.</summary>
|
||||
MaintenanceWindowUpdated,
|
||||
|
||||
/// <summary>Maintenance window deleted.</summary>
|
||||
MaintenanceWindowDeleted,
|
||||
|
||||
/// <summary>Operator override created.</summary>
|
||||
OverrideCreated,
|
||||
|
||||
/// <summary>Operator override expired.</summary>
|
||||
OverrideExpired,
|
||||
|
||||
/// <summary>Operator override revoked.</summary>
|
||||
OverrideRevoked,
|
||||
|
||||
/// <summary>Override used to bypass suppression.</summary>
|
||||
OverrideUsed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for audit log retrieval.
|
||||
/// </summary>
|
||||
public sealed record SuppressionAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID (required).
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of time range.
|
||||
/// </summary>
|
||||
public DateTimeOffset? From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of time range.
|
||||
/// </summary>
|
||||
public DateTimeOffset? To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by action type.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SuppressionAuditAction>? Actions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by actor.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by resource type.
|
||||
/// </summary>
|
||||
public string? ResourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by resource ID.
|
||||
/// </summary>
|
||||
public string? ResourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum entries to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing tenant-level and event-kind throttle configurations.
|
||||
/// </summary>
|
||||
public interface IThrottleConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the effective throttle configuration for a tenant and event kind.
|
||||
/// Follows hierarchy: event kind → tenant → global defaults.
|
||||
/// </summary>
|
||||
Task<EffectiveThrottleConfig> GetEffectiveConfigAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant-level default throttle configuration.
|
||||
/// </summary>
|
||||
Task<TenantThrottleConfig?> GetTenantConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or updates the tenant-level default throttle configuration.
|
||||
/// </summary>
|
||||
Task<TenantThrottleConfig> SetTenantConfigAsync(
|
||||
string tenantId,
|
||||
TenantThrottleConfigUpdate config,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets event-kind specific throttle overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EventKindThrottleConfig>> ListEventKindConfigsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or updates an event-kind specific throttle configuration.
|
||||
/// </summary>
|
||||
Task<EventKindThrottleConfig> SetEventKindConfigAsync(
|
||||
string tenantId,
|
||||
string eventKindPattern,
|
||||
EventKindThrottleConfigUpdate config,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an event-kind specific throttle configuration.
|
||||
/// </summary>
|
||||
Task<bool> RemoveEventKindConfigAsync(
|
||||
string tenantId,
|
||||
string eventKindPattern,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective throttle configuration after hierarchy resolution.
|
||||
/// </summary>
|
||||
public sealed record EffectiveThrottleConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Throttle window duration.
|
||||
/// </summary>
|
||||
public required TimeSpan Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum events within the window.
|
||||
/// </summary>
|
||||
public required int MaxEvents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of this configuration (global, tenant, event_kind).
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern matched (for event kind overrides).
|
||||
/// </summary>
|
||||
public string? MatchedPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Burst allowance above the max.
|
||||
/// </summary>
|
||||
public int BurstAllowance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown period after throttle triggers.
|
||||
/// </summary>
|
||||
public TimeSpan? CooldownPeriod { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-level default throttle configuration.
|
||||
/// </summary>
|
||||
public sealed record TenantThrottleConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled for this tenant.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum events within the window.
|
||||
/// </summary>
|
||||
public int DefaultMaxEvents { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Burst allowance above the max.
|
||||
/// </summary>
|
||||
public int BurstAllowance { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown period after throttle triggers.
|
||||
/// </summary>
|
||||
public TimeSpan? CooldownPeriod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who last updated this configuration.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the configuration was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update request for tenant throttle configuration.
|
||||
/// </summary>
|
||||
public sealed record TenantThrottleConfigUpdate
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public TimeSpan? DefaultWindow { get; init; }
|
||||
public int? DefaultMaxEvents { get; init; }
|
||||
public int? BurstAllowance { get; init; }
|
||||
public TimeSpan? CooldownPeriod { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event-kind specific throttle configuration.
|
||||
/// </summary>
|
||||
public sealed record EventKindThrottleConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind pattern (supports prefix matching with '*').
|
||||
/// </summary>
|
||||
public required string EventKindPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled for this event kind.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle window for this event kind.
|
||||
/// </summary>
|
||||
public TimeSpan? Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum events for this event kind.
|
||||
/// </summary>
|
||||
public int? MaxEvents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Burst allowance for this event kind.
|
||||
/// </summary>
|
||||
public int? BurstAllowance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown period after throttle.
|
||||
/// </summary>
|
||||
public TimeSpan? CooldownPeriod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority when multiple patterns match (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Who created/updated this configuration.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the configuration was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update request for event kind throttle configuration.
|
||||
/// </summary>
|
||||
public sealed record EventKindThrottleConfigUpdate
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public TimeSpan? Window { get; init; }
|
||||
public int? MaxEvents { get; init; }
|
||||
public int? BurstAllowance { get; init; }
|
||||
public TimeSpan? CooldownPeriod { get; init; }
|
||||
public int? Priority { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Manages incident lifecycle (create, update, acknowledge, resolve).
|
||||
/// </summary>
|
||||
public interface IIncidentManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or creates an incident for the correlation key.
|
||||
/// </summary>
|
||||
Task<IncidentState> GetOrCreateIncidentAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
string eventKind,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records an event against an incident.
|
||||
/// </summary>
|
||||
Task<IncidentState> RecordEventAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an incident.
|
||||
/// </summary>
|
||||
Task<IncidentState?> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string actor,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an incident.
|
||||
/// </summary>
|
||||
Task<IncidentState?> ResolveAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string actor,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an incident by ID.
|
||||
/// </summary>
|
||||
Task<IncidentState?> GetAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists incidents for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IncidentState>> ListAsync(
|
||||
string tenantId,
|
||||
IncidentStatus? statusFilter = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident lifecycle status.
|
||||
/// </summary>
|
||||
public enum IncidentStatus
|
||||
{
|
||||
Open,
|
||||
Acknowledged,
|
||||
Resolved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentState
|
||||
{
|
||||
public required string IncidentId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string CorrelationKey { get; init; }
|
||||
public required string EventKind { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required IncidentStatus Status { get; set; }
|
||||
public required int EventCount { get; set; }
|
||||
public required DateTimeOffset FirstOccurrence { get; init; }
|
||||
public required DateTimeOffset LastOccurrence { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgeComment { get; set; }
|
||||
public string? ResolvedBy { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public string? ResolutionReason { get; set; }
|
||||
public List<string> EventIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIncidentManager"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIncidentManager : IIncidentManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IncidentState> _incidents = new();
|
||||
private readonly ConcurrentDictionary<string, string> _correlationKeyToIncident = new();
|
||||
private readonly IncidentManagerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryIncidentManager> _logger;
|
||||
|
||||
public InMemoryIncidentManager(
|
||||
IOptions<IncidentManagerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryIncidentManager> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IncidentState> GetOrCreateIncidentAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
string eventKind,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lookupKey = BuildLookupKey(tenantId, correlationKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for existing open incident
|
||||
if (_correlationKeyToIncident.TryGetValue(lookupKey, out var existingId) &&
|
||||
_incidents.TryGetValue(existingId, out var existing) &&
|
||||
existing.Status != IncidentStatus.Resolved)
|
||||
{
|
||||
// Check if incident is within correlation window
|
||||
if (now - existing.LastOccurrence <= _options.CorrelationWindow)
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new incident
|
||||
var incidentId = $"inc-{Guid.NewGuid():N}"[..20];
|
||||
var incident = new IncidentState
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
TenantId = tenantId,
|
||||
CorrelationKey = correlationKey,
|
||||
EventKind = eventKind,
|
||||
Title = title,
|
||||
Status = IncidentStatus.Open,
|
||||
EventCount = 0,
|
||||
FirstOccurrence = now,
|
||||
LastOccurrence = now
|
||||
};
|
||||
|
||||
_incidents[incidentId] = incident;
|
||||
_correlationKeyToIncident[lookupKey] = incidentId;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created incident {IncidentId} for tenant {TenantId}, correlation key {Key}.",
|
||||
incidentId, tenantId, correlationKey);
|
||||
|
||||
return Task.FromResult(incident);
|
||||
}
|
||||
|
||||
public Task<IncidentState> RecordEventAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_incidents.TryGetValue(incidentId, out var incident))
|
||||
{
|
||||
throw new InvalidOperationException($"Incident {incidentId} not found.");
|
||||
}
|
||||
|
||||
incident.EventCount++;
|
||||
incident.LastOccurrence = _timeProvider.GetUtcNow();
|
||||
incident.EventIds.Add(eventId);
|
||||
|
||||
// If acknowledged, reopen on new event (based on config)
|
||||
if (_options.ReopenOnNewEvent && incident.Status == IncidentStatus.Acknowledged)
|
||||
{
|
||||
incident.Status = IncidentStatus.Open;
|
||||
_logger.LogInformation(
|
||||
"Reopened incident {IncidentId} due to new event.",
|
||||
incidentId);
|
||||
}
|
||||
|
||||
return Task.FromResult(incident);
|
||||
}
|
||||
|
||||
public Task<IncidentState?> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string actor,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_incidents.TryGetValue(incidentId, out var incident) ||
|
||||
incident.TenantId != tenantId)
|
||||
{
|
||||
return Task.FromResult<IncidentState?>(null);
|
||||
}
|
||||
|
||||
if (incident.Status == IncidentStatus.Resolved)
|
||||
{
|
||||
return Task.FromResult<IncidentState?>(incident);
|
||||
}
|
||||
|
||||
incident.Status = IncidentStatus.Acknowledged;
|
||||
incident.AcknowledgedBy = actor;
|
||||
incident.AcknowledgedAt = _timeProvider.GetUtcNow();
|
||||
incident.AcknowledgeComment = comment;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Acknowledged incident {IncidentId} by {Actor}.",
|
||||
incidentId, actor);
|
||||
|
||||
return Task.FromResult<IncidentState?>(incident);
|
||||
}
|
||||
|
||||
public Task<IncidentState?> ResolveAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string actor,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_incidents.TryGetValue(incidentId, out var incident) ||
|
||||
incident.TenantId != tenantId)
|
||||
{
|
||||
return Task.FromResult<IncidentState?>(null);
|
||||
}
|
||||
|
||||
incident.Status = IncidentStatus.Resolved;
|
||||
incident.ResolvedBy = actor;
|
||||
incident.ResolvedAt = _timeProvider.GetUtcNow();
|
||||
incident.ResolutionReason = reason;
|
||||
|
||||
// Remove from correlation key lookup so new events create new incident
|
||||
var lookupKey = BuildLookupKey(tenantId, incident.CorrelationKey);
|
||||
_correlationKeyToIncident.TryRemove(lookupKey, out _);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolved incident {IncidentId} by {Actor}: {Reason}.",
|
||||
incidentId, actor, reason ?? "N/A");
|
||||
|
||||
return Task.FromResult<IncidentState?>(incident);
|
||||
}
|
||||
|
||||
public Task<IncidentState?> GetAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_incidents.TryGetValue(incidentId, out var incident) &&
|
||||
incident.TenantId == tenantId)
|
||||
{
|
||||
return Task.FromResult<IncidentState?>(incident);
|
||||
}
|
||||
|
||||
return Task.FromResult<IncidentState?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IncidentState>> ListAsync(
|
||||
string tenantId,
|
||||
IncidentStatus? statusFilter = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _incidents.Values
|
||||
.Where(i => i.TenantId == tenantId);
|
||||
|
||||
if (statusFilter.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.Status == statusFilter.Value);
|
||||
}
|
||||
|
||||
var result = query
|
||||
.OrderByDescending(i => i.LastOccurrence)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IncidentState>>(result);
|
||||
}
|
||||
|
||||
private static string BuildLookupKey(string tenantId, string correlationKey) =>
|
||||
$"{tenantId}:{correlationKey}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for incident manager.
|
||||
/// </summary>
|
||||
public sealed class IncidentManagerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:IncidentManager";
|
||||
|
||||
/// <summary>
|
||||
/// Time window for correlating events into the same incident.
|
||||
/// </summary>
|
||||
public TimeSpan CorrelationWindow { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to reopen acknowledged incidents on new events.
|
||||
/// </summary>
|
||||
public bool ReopenOnNewEvent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-resolve incidents after this duration of inactivity.
|
||||
/// </summary>
|
||||
public TimeSpan? AutoResolveAfter { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks and enforces throttling of notifications by correlation key.
|
||||
/// </summary>
|
||||
public interface INotifyThrottler
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an event occurrence for throttle tracking.
|
||||
/// </summary>
|
||||
Task RecordEventAsync(string tenantId, string correlationKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the correlation key is currently throttled.
|
||||
/// </summary>
|
||||
Task<ThrottleCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
TimeSpan? window,
|
||||
int? maxEvents,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears throttle state for a key (e.g., after incident resolution).
|
||||
/// </summary>
|
||||
Task ClearAsync(string tenantId, string correlationKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory throttler implementation with sliding window.
|
||||
/// </summary>
|
||||
public sealed class InMemoryNotifyThrottler : INotifyThrottler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ThrottleState> _states = new();
|
||||
private readonly ThrottlerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryNotifyThrottler> _logger;
|
||||
|
||||
public InMemoryNotifyThrottler(
|
||||
IOptions<ThrottlerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryNotifyThrottler> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task RecordEventAsync(string tenantId, string correlationKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, correlationKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_states.AddOrUpdate(
|
||||
key,
|
||||
_ => new ThrottleState { Events = [now] },
|
||||
(_, existing) =>
|
||||
{
|
||||
existing.Events.Add(now);
|
||||
return existing;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ThrottleCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
TimeSpan? window,
|
||||
int? maxEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, correlationKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var effectiveWindow = window ?? _options.DefaultWindow;
|
||||
var effectiveMax = maxEvents ?? _options.DefaultMaxEvents;
|
||||
|
||||
if (!_states.TryGetValue(key, out var state))
|
||||
{
|
||||
return Task.FromResult(ThrottleCheckResult.NotThrottled());
|
||||
}
|
||||
|
||||
// Clean old events outside the window
|
||||
var cutoff = now - effectiveWindow;
|
||||
state.Events.RemoveAll(e => e < cutoff);
|
||||
|
||||
var recentCount = state.Events.Count;
|
||||
|
||||
if (recentCount >= effectiveMax)
|
||||
{
|
||||
var oldestInWindow = state.Events.Min();
|
||||
var resetIn = oldestInWindow + effectiveWindow - now;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Throttled key {Key}: {Count} events in window (max {Max}), reset in {Reset}.",
|
||||
key, recentCount, effectiveMax, resetIn);
|
||||
|
||||
return Task.FromResult(ThrottleCheckResult.Throttled(recentCount, resetIn > TimeSpan.Zero ? resetIn : null));
|
||||
}
|
||||
|
||||
return Task.FromResult(ThrottleCheckResult.NotThrottled(recentCount));
|
||||
}
|
||||
|
||||
public Task ClearAsync(string tenantId, string correlationKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, correlationKey);
|
||||
_states.TryRemove(key, out _);
|
||||
|
||||
_logger.LogDebug("Cleared throttle state for key {Key}.", key);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BuildKey(string tenantId, string correlationKey) =>
|
||||
$"{tenantId}:{correlationKey}";
|
||||
|
||||
private sealed class ThrottleState
|
||||
{
|
||||
public List<DateTimeOffset> Events { get; init; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the throttler.
|
||||
/// </summary>
|
||||
public sealed class ThrottlerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:Throttler";
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum events before throttling.
|
||||
/// </summary>
|
||||
public int DefaultMaxEvents { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IOperatorOverrideService"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryOperatorOverrideService : IOperatorOverrideService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, OperatorOverride>> _overrides = new();
|
||||
private readonly ISuppressionAuditLogger _auditLogger;
|
||||
private readonly OperatorOverrideOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryOperatorOverrideService> _logger;
|
||||
|
||||
public InMemoryOperatorOverrideService(
|
||||
ISuppressionAuditLogger auditLogger,
|
||||
IOptions<OperatorOverrideOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryOperatorOverrideService> logger)
|
||||
{
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<OperatorOverride> CreateOverrideAsync(
|
||||
string tenantId,
|
||||
OperatorOverrideCreate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Validate duration limits
|
||||
if (request.Duration > _options.MaxDuration)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Override duration ({request.Duration}) exceeds maximum allowed ({_options.MaxDuration}).",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
if (request.Duration < _options.MinDuration)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Override duration ({request.Duration}) is below minimum allowed ({_options.MinDuration}).",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
var tenantOverrides = _overrides.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, OperatorOverride>());
|
||||
|
||||
// Check active override limit
|
||||
var activeCount = tenantOverrides.Values.Count(o => o.Status == OverrideStatus.Active && o.ExpiresAt > now);
|
||||
if (activeCount >= _options.MaxActiveOverridesPerTenant)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum active overrides ({_options.MaxActiveOverridesPerTenant}) reached for tenant.");
|
||||
}
|
||||
|
||||
var overrideId = $"ovr-{Guid.NewGuid():N}"[..16];
|
||||
var effectiveFrom = request.EffectiveFrom ?? now;
|
||||
|
||||
var @override = new OperatorOverride
|
||||
{
|
||||
OverrideId = overrideId,
|
||||
TenantId = tenantId,
|
||||
Type = request.Type,
|
||||
Reason = request.Reason,
|
||||
EffectiveFrom = effectiveFrom,
|
||||
ExpiresAt = effectiveFrom + request.Duration,
|
||||
EventKinds = request.EventKinds ?? [],
|
||||
CorrelationKeys = request.CorrelationKeys ?? [],
|
||||
MaxUsageCount = request.MaxUsageCount,
|
||||
UsageCount = 0,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now,
|
||||
Status = effectiveFrom <= now ? OverrideStatus.Active : OverrideStatus.Active
|
||||
};
|
||||
|
||||
if (!tenantOverrides.TryAdd(overrideId, @override))
|
||||
{
|
||||
throw new InvalidOperationException($"Override {overrideId} already exists.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created operator override {OverrideId} for tenant {TenantId} by {Actor}: {Type}, expires {ExpiresAt}.",
|
||||
overrideId, tenantId, actor, request.Type, @override.ExpiresAt);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.OverrideCreated,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = overrideId,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = request.Type.ToString(),
|
||||
["reason"] = request.Reason,
|
||||
["duration"] = request.Duration.TotalMinutes,
|
||||
["expiresAt"] = @override.ExpiresAt,
|
||||
["eventKinds"] = request.EventKinds ?? [],
|
||||
["correlationKeys"] = request.CorrelationKeys ?? [],
|
||||
["maxUsageCount"] = request.MaxUsageCount ?? -1
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return @override;
|
||||
}
|
||||
|
||||
public Task<OperatorOverride?> GetOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue(tenantId, out var tenantOverrides))
|
||||
{
|
||||
return Task.FromResult<OperatorOverride?>(null);
|
||||
}
|
||||
|
||||
tenantOverrides.TryGetValue(overrideId, out var @override);
|
||||
|
||||
// Update status if expired
|
||||
if (@override is not null)
|
||||
{
|
||||
@override = UpdateOverrideStatus(@override);
|
||||
}
|
||||
|
||||
return Task.FromResult(@override);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OperatorOverride>> ListActiveOverridesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue(tenantId, out var tenantOverrides))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<OperatorOverride>>([]);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var active = tenantOverrides.Values
|
||||
.Select(UpdateOverrideStatus)
|
||||
.Where(o => o.Status == OverrideStatus.Active)
|
||||
.OrderBy(o => o.ExpiresAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<OperatorOverride>>(active);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeOverrideAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string actor,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue(tenantId, out var tenantOverrides))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tenantOverrides.TryGetValue(overrideId, out var existing))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existing.Status != OverrideStatus.Active)
|
||||
{
|
||||
return false; // Already inactive
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var revoked = existing with
|
||||
{
|
||||
Status = OverrideStatus.Revoked,
|
||||
RevokedBy = actor,
|
||||
RevokedAt = now,
|
||||
RevocationReason = reason
|
||||
};
|
||||
|
||||
if (!tenantOverrides.TryUpdate(overrideId, revoked, existing))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Revoked operator override {OverrideId} for tenant {TenantId} by {Actor}: {Reason}.",
|
||||
overrideId, tenantId, actor, reason ?? "(no reason)");
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.OverrideRevoked,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = overrideId,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = reason ?? "(no reason)",
|
||||
["originalReason"] = existing.Reason,
|
||||
["usageCount"] = existing.UsageCount,
|
||||
["remainingTime"] = (existing.ExpiresAt - now).TotalMinutes
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<OverrideCheckResult> CheckOverrideAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
string? correlationKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue(tenantId, out var tenantOverrides))
|
||||
{
|
||||
return Task.FromResult(OverrideCheckResult.NoOverride());
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Find applicable override
|
||||
var applicable = tenantOverrides.Values
|
||||
.Select(UpdateOverrideStatus)
|
||||
.Where(o => o.Status == OverrideStatus.Active)
|
||||
.Where(o => o.EffectiveFrom <= now && o.ExpiresAt > now)
|
||||
.Where(o => MatchesEventKind(o, eventKind))
|
||||
.Where(o => MatchesCorrelationKey(o, correlationKey))
|
||||
.Where(o => o.MaxUsageCount is null || o.UsageCount < o.MaxUsageCount)
|
||||
.OrderByDescending(o => o.CreatedAt) // Prefer newer overrides
|
||||
.FirstOrDefault();
|
||||
|
||||
if (applicable is null)
|
||||
{
|
||||
return Task.FromResult(OverrideCheckResult.NoOverride());
|
||||
}
|
||||
|
||||
return Task.FromResult(OverrideCheckResult.WithOverride(applicable));
|
||||
}
|
||||
|
||||
public async Task RecordOverrideUsageAsync(
|
||||
string tenantId,
|
||||
string overrideId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue(tenantId, out var tenantOverrides))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantOverrides.TryGetValue(overrideId, out var existing))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
UsageCount = existing.UsageCount + 1,
|
||||
Status = existing.MaxUsageCount.HasValue && existing.UsageCount + 1 >= existing.MaxUsageCount
|
||||
? OverrideStatus.Exhausted
|
||||
: existing.Status
|
||||
};
|
||||
|
||||
tenantOverrides.TryUpdate(overrideId, updated, existing);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Override {OverrideId} used for event kind {EventKind} (usage: {Count}/{Max}).",
|
||||
overrideId, eventKind, updated.UsageCount, updated.MaxUsageCount?.ToString() ?? "unlimited");
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = "system",
|
||||
Action = SuppressionAuditAction.OverrideUsed,
|
||||
ResourceType = "OperatorOverride",
|
||||
ResourceId = overrideId,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["eventKind"] = eventKind,
|
||||
["usageCount"] = updated.UsageCount,
|
||||
["maxUsageCount"] = updated.MaxUsageCount ?? -1,
|
||||
["exhausted"] = updated.Status == OverrideStatus.Exhausted
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private OperatorOverride UpdateOverrideStatus(OperatorOverride @override)
|
||||
{
|
||||
if (@override.Status != OverrideStatus.Active)
|
||||
{
|
||||
return @override;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (@override.ExpiresAt <= now)
|
||||
{
|
||||
return @override with { Status = OverrideStatus.Expired };
|
||||
}
|
||||
|
||||
if (@override.MaxUsageCount.HasValue && @override.UsageCount >= @override.MaxUsageCount)
|
||||
{
|
||||
return @override with { Status = OverrideStatus.Exhausted };
|
||||
}
|
||||
|
||||
return @override;
|
||||
}
|
||||
|
||||
private static bool MatchesEventKind(OperatorOverride @override, string eventKind)
|
||||
{
|
||||
if (@override.EventKinds.Count == 0)
|
||||
{
|
||||
return true; // All event kinds
|
||||
}
|
||||
|
||||
return @override.EventKinds.Any(k =>
|
||||
eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase) ||
|
||||
k == "*");
|
||||
}
|
||||
|
||||
private static bool MatchesCorrelationKey(OperatorOverride @override, string? correlationKey)
|
||||
{
|
||||
if (@override.CorrelationKeys.Count == 0)
|
||||
{
|
||||
return true; // All correlation keys
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(correlationKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return @override.CorrelationKeys.Contains(correlationKey, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for operator overrides.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:OperatorOverride";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum override duration.
|
||||
/// </summary>
|
||||
public TimeSpan MinDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum override duration.
|
||||
/// </summary>
|
||||
public TimeSpan MaxDuration { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum active overrides per tenant.
|
||||
/// </summary>
|
||||
public int MaxActiveOverridesPerTenant { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Whether overrides require a reason.
|
||||
/// </summary>
|
||||
public bool RequireReason { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum reason length.
|
||||
/// </summary>
|
||||
public int MinReasonLength { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IQuietHourCalendarService"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryQuietHourCalendarService : IQuietHourCalendarService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, QuietHourCalendar>> _calendars = new();
|
||||
private readonly ISuppressionAuditLogger _auditLogger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryQuietHourCalendarService> _logger;
|
||||
|
||||
public InMemoryQuietHourCalendarService(
|
||||
ISuppressionAuditLogger auditLogger,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryQuietHourCalendarService> logger)
|
||||
{
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QuietHourCalendar>> ListCalendarsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_calendars.TryGetValue(tenantId, out var tenantCalendars))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<QuietHourCalendar>>([]);
|
||||
}
|
||||
|
||||
var calendars = tenantCalendars.Values
|
||||
.OrderBy(c => c.Priority)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<QuietHourCalendar>>(calendars);
|
||||
}
|
||||
|
||||
public Task<QuietHourCalendar?> GetCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_calendars.TryGetValue(tenantId, out var tenantCalendars))
|
||||
{
|
||||
return Task.FromResult<QuietHourCalendar?>(null);
|
||||
}
|
||||
|
||||
tenantCalendars.TryGetValue(calendarId, out var calendar);
|
||||
return Task.FromResult(calendar);
|
||||
}
|
||||
|
||||
public async Task<QuietHourCalendar> CreateCalendarAsync(
|
||||
string tenantId,
|
||||
QuietHourCalendarCreate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantCalendars = _calendars.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, QuietHourCalendar>());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var calendarId = $"cal-{Guid.NewGuid():N}"[..16];
|
||||
|
||||
var calendar = new QuietHourCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Enabled = request.Enabled,
|
||||
Priority = request.Priority,
|
||||
Scopes = request.Scopes ?? [],
|
||||
EventKinds = request.EventKinds ?? [],
|
||||
ExcludedEventKinds = request.ExcludedEventKinds ?? [],
|
||||
Schedules = request.Schedules ?? [],
|
||||
Timezone = request.Timezone ?? "UTC",
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
if (!tenantCalendars.TryAdd(calendarId, calendar))
|
||||
{
|
||||
throw new InvalidOperationException($"Calendar {calendarId} already exists.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created quiet hour calendar {CalendarId} '{Name}' for tenant {TenantId} by {Actor}.",
|
||||
calendarId, request.Name, tenantId, actor);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.CalendarCreated,
|
||||
ResourceType = "QuietHourCalendar",
|
||||
ResourceId = calendarId,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = request.Name,
|
||||
["enabled"] = request.Enabled,
|
||||
["scheduleCount"] = request.Schedules?.Count ?? 0
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
public async Task<QuietHourCalendar?> UpdateCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
QuietHourCalendarUpdate request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_calendars.TryGetValue(tenantId, out var tenantCalendars))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tenantCalendars.TryGetValue(calendarId, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var changes = new Dictionary<string, object>();
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Scopes = request.Scopes ?? existing.Scopes,
|
||||
EventKinds = request.EventKinds ?? existing.EventKinds,
|
||||
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
|
||||
Schedules = request.Schedules ?? existing.Schedules,
|
||||
Timezone = request.Timezone ?? existing.Timezone,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// Track changes for audit
|
||||
if (request.Name is not null && request.Name != existing.Name)
|
||||
changes["name"] = new { from = existing.Name, to = request.Name };
|
||||
if (request.Enabled.HasValue && request.Enabled != existing.Enabled)
|
||||
changes["enabled"] = new { from = existing.Enabled, to = request.Enabled };
|
||||
if (request.Priority.HasValue && request.Priority != existing.Priority)
|
||||
changes["priority"] = new { from = existing.Priority, to = request.Priority };
|
||||
if (request.Schedules is not null)
|
||||
changes["scheduleCount"] = new { from = existing.Schedules.Count, to = request.Schedules.Count };
|
||||
|
||||
if (!tenantCalendars.TryUpdate(calendarId, updated, existing))
|
||||
{
|
||||
return null; // Concurrent modification
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated quiet hour calendar {CalendarId} for tenant {TenantId} by {Actor}.",
|
||||
calendarId, tenantId, actor);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.CalendarUpdated,
|
||||
ResourceType = "QuietHourCalendar",
|
||||
ResourceId = calendarId,
|
||||
Details = changes
|
||||
}, cancellationToken);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_calendars.TryGetValue(tenantId, out var tenantCalendars))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tenantCalendars.TryRemove(calendarId, out var removed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deleted quiet hour calendar {CalendarId} '{Name}' for tenant {TenantId} by {Actor}.",
|
||||
calendarId, removed.Name, tenantId, actor);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.CalendarDeleted,
|
||||
ResourceType = "QuietHourCalendar",
|
||||
ResourceId = calendarId,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = removed.Name,
|
||||
["wasEnabled"] = removed.Enabled
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<CalendarEvaluationResult> EvaluateCalendarsAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
IReadOnlyList<string>? scopes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_calendars.TryGetValue(tenantId, out var tenantCalendars))
|
||||
{
|
||||
return Task.FromResult(CalendarEvaluationResult.NotSuppressed());
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var effectiveScopes = scopes ?? [];
|
||||
|
||||
// Evaluate calendars by priority
|
||||
var orderedCalendars = tenantCalendars.Values
|
||||
.Where(c => c.Enabled)
|
||||
.OrderBy(c => c.Priority)
|
||||
.ToList();
|
||||
|
||||
foreach (var calendar in orderedCalendars)
|
||||
{
|
||||
// Check scope match
|
||||
if (calendar.Scopes.Count > 0 &&
|
||||
!effectiveScopes.Any(s => calendar.Scopes.Contains(s, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check event kind match (if specified)
|
||||
if (calendar.EventKinds.Count > 0 &&
|
||||
!calendar.EventKinds.Any(k => eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check excluded event kinds
|
||||
if (calendar.ExcludedEventKinds.Any(k =>
|
||||
eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Evaluate schedules
|
||||
var scheduleResult = EvaluateSchedules(calendar, now);
|
||||
if (scheduleResult is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventKind} suppressed by calendar {CalendarId} schedule {ScheduleName} until {Until}.",
|
||||
eventKind, calendar.CalendarId, scheduleResult.Value.scheduleName, scheduleResult.Value.until);
|
||||
|
||||
return Task.FromResult(CalendarEvaluationResult.Suppressed(
|
||||
calendar.CalendarId,
|
||||
calendar.Name,
|
||||
scheduleResult.Value.scheduleName,
|
||||
$"Quiet hours: {calendar.Name} - {scheduleResult.Value.scheduleName}",
|
||||
scheduleResult.Value.until));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(CalendarEvaluationResult.NotSuppressed());
|
||||
}
|
||||
|
||||
private (string scheduleName, DateTimeOffset until)? EvaluateSchedules(
|
||||
QuietHourCalendar calendar,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
TimeZoneInfo tz;
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(calendar.Timezone);
|
||||
}
|
||||
catch
|
||||
{
|
||||
tz = TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
var localNow = TimeZoneInfo.ConvertTime(now, tz);
|
||||
var currentTime = localNow.TimeOfDay;
|
||||
var currentDay = (int)localNow.DayOfWeek;
|
||||
var currentDate = DateOnly.FromDateTime(localNow.DateTime);
|
||||
|
||||
foreach (var schedule in calendar.Schedules.Where(s => s.Enabled))
|
||||
{
|
||||
// Check excluded dates
|
||||
if (schedule.ExcludedDates.Contains(currentDate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if schedule applies today
|
||||
bool appliestoday;
|
||||
if (schedule.SpecificDates.Count > 0)
|
||||
{
|
||||
appliestoday = schedule.SpecificDates.Contains(currentDate);
|
||||
}
|
||||
else if (schedule.DaysOfWeek.Count > 0)
|
||||
{
|
||||
appliestoday = schedule.DaysOfWeek.Contains(currentDay);
|
||||
}
|
||||
else
|
||||
{
|
||||
appliestoday = true; // All days
|
||||
}
|
||||
|
||||
if (!appliestoday)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse times
|
||||
if (!TryParseTime(schedule.StartTime, out var startTime) ||
|
||||
!TryParseTime(schedule.EndTime, out var endTime))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if current time is within schedule
|
||||
bool inSchedule;
|
||||
DateTimeOffset suppressedUntil;
|
||||
|
||||
if (startTime <= endTime)
|
||||
{
|
||||
// Same-day window (e.g., 09:00 to 17:00)
|
||||
inSchedule = currentTime >= startTime && currentTime < endTime;
|
||||
suppressedUntil = new DateTimeOffset(
|
||||
localNow.Date + endTime,
|
||||
tz.GetUtcOffset(localNow.Date + endTime));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overnight window (e.g., 22:00 to 08:00)
|
||||
inSchedule = currentTime >= startTime || currentTime < endTime;
|
||||
var endDate = currentTime >= startTime
|
||||
? localNow.Date.AddDays(1)
|
||||
: localNow.Date;
|
||||
suppressedUntil = new DateTimeOffset(
|
||||
endDate + endTime,
|
||||
tz.GetUtcOffset(endDate + endTime));
|
||||
}
|
||||
|
||||
if (inSchedule)
|
||||
{
|
||||
return (schedule.Name, suppressedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseTime(string? timeString, out TimeSpan time)
|
||||
{
|
||||
time = TimeSpan.Zero;
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TimeSpan.TryParse(timeString, out time);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing quiet hours calendars with multiple named schedules per tenant.
|
||||
/// </summary>
|
||||
public interface IQuietHoursCalendarService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all calendars for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuietHoursCalendar>> ListCalendarsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific calendar.
|
||||
/// </summary>
|
||||
Task<QuietHoursCalendar?> GetCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a calendar.
|
||||
/// </summary>
|
||||
Task<QuietHoursCalendar> UpsertCalendarAsync(
|
||||
QuietHoursCalendar calendar,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a calendar.
|
||||
/// </summary>
|
||||
Task<bool> DeleteCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates all calendars to check if quiet hours are active.
|
||||
/// </summary>
|
||||
Task<QuietHoursEvaluationResult> EvaluateAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
DateTimeOffset? evaluationTime = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours calendar with named schedules.
|
||||
/// </summary>
|
||||
public sealed record QuietHoursCalendar
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique calendar ID.
|
||||
/// </summary>
|
||||
public required string CalendarId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the calendar is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority (lower = higher priority when multiple calendars match).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Schedules in this calendar.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<QuietHoursScheduleEntry> Schedules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds to exclude (always notify even during quiet hours).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ExcludedEventKinds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds to include (only suppress these, null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? IncludedEventKinds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who last updated.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A schedule entry within a calendar.
|
||||
/// </summary>
|
||||
public sealed record QuietHoursScheduleEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Schedule name (e.g., "Weekday Nights", "Weekend All Day").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start time (24h format, e.g., "22:00").
|
||||
/// </summary>
|
||||
public required string StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End time (24h format, e.g., "08:00").
|
||||
/// </summary>
|
||||
public required string EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days of week (0=Sunday, 6=Saturday). Null = all days.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int>? DaysOfWeek { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timezone (IANA format). Null = UTC.
|
||||
/// </summary>
|
||||
public string? Timezone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this schedule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of quiet hours evaluation.
|
||||
/// </summary>
|
||||
public sealed record QuietHoursEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether quiet hours are active.
|
||||
/// </summary>
|
||||
public required bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The calendar that matched (if any).
|
||||
/// </summary>
|
||||
public string? MatchedCalendarId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The calendar name that matched.
|
||||
/// </summary>
|
||||
public string? MatchedCalendarName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The schedule entry that matched.
|
||||
/// </summary>
|
||||
public string? MatchedScheduleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When quiet hours end.
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndsAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
public static QuietHoursEvaluationResult NotActive() => new() { IsActive = false };
|
||||
|
||||
public static QuietHoursEvaluationResult Active(
|
||||
string calendarId,
|
||||
string calendarName,
|
||||
string scheduleName,
|
||||
DateTimeOffset endsAt) => new()
|
||||
{
|
||||
IsActive = true,
|
||||
MatchedCalendarId = calendarId,
|
||||
MatchedCalendarName = calendarName,
|
||||
MatchedScheduleName = scheduleName,
|
||||
EndsAt = endsAt,
|
||||
Reason = $"Quiet hours '{calendarName}' ({scheduleName}) active until {endsAt:HH:mm}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of quiet hours calendar service.
|
||||
/// </summary>
|
||||
public sealed class InMemoryQuietHoursCalendarService : IQuietHoursCalendarService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, QuietHoursCalendar> _calendars = new();
|
||||
private readonly INotifyAuditRepository? _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryQuietHoursCalendarService> _logger;
|
||||
|
||||
public InMemoryQuietHoursCalendarService(
|
||||
INotifyAuditRepository? auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryQuietHoursCalendarService> logger)
|
||||
{
|
||||
_auditRepository = auditRepository;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QuietHoursCalendar>> ListCalendarsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var calendars = _calendars.Values
|
||||
.Where(c => c.TenantId == tenantId)
|
||||
.OrderBy(c => c.Priority)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<QuietHoursCalendar>>(calendars);
|
||||
}
|
||||
|
||||
public Task<QuietHoursCalendar?> GetCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, calendarId);
|
||||
_calendars.TryGetValue(key, out var calendar);
|
||||
return Task.FromResult(calendar);
|
||||
}
|
||||
|
||||
public async Task<QuietHoursCalendar> UpsertCalendarAsync(
|
||||
QuietHoursCalendar calendar,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(calendar);
|
||||
|
||||
var key = BuildKey(calendar.TenantId, calendar.CalendarId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var isNew = !_calendars.ContainsKey(key);
|
||||
|
||||
var updated = calendar with
|
||||
{
|
||||
CreatedAt = isNew ? now : calendar.CreatedAt,
|
||||
CreatedBy = isNew ? actor : calendar.CreatedBy,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
_calendars[key] = updated;
|
||||
|
||||
// Audit
|
||||
if (_auditRepository is not null)
|
||||
{
|
||||
await _auditRepository.AppendAsync(
|
||||
calendar.TenantId,
|
||||
isNew ? "quiet_hours_calendar_created" : "quiet_hours_calendar_updated",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["calendarId"] = calendar.CalendarId,
|
||||
["name"] = calendar.Name,
|
||||
["enabled"] = calendar.Enabled.ToString(),
|
||||
["scheduleCount"] = calendar.Schedules.Count.ToString()
|
||||
},
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"{Action} quiet hours calendar {CalendarId} for tenant {TenantId}.",
|
||||
isNew ? "Created" : "Updated", calendar.CalendarId, calendar.TenantId);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCalendarAsync(
|
||||
string tenantId,
|
||||
string calendarId,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, calendarId);
|
||||
var removed = _calendars.TryRemove(key, out _);
|
||||
|
||||
if (removed && _auditRepository is not null)
|
||||
{
|
||||
await _auditRepository.AppendAsync(
|
||||
tenantId,
|
||||
"quiet_hours_calendar_deleted",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["calendarId"] = calendarId
|
||||
},
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleted quiet hours calendar {CalendarId} for tenant {TenantId}.",
|
||||
calendarId, tenantId);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public Task<QuietHoursEvaluationResult> EvaluateAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
DateTimeOffset? evaluationTime = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = evaluationTime ?? _timeProvider.GetUtcNow();
|
||||
|
||||
var calendars = _calendars.Values
|
||||
.Where(c => c.TenantId == tenantId && c.Enabled)
|
||||
.OrderBy(c => c.Priority)
|
||||
.ToList();
|
||||
|
||||
foreach (var calendar in calendars)
|
||||
{
|
||||
// Check excluded event kinds
|
||||
if (calendar.ExcludedEventKinds?.Any(k =>
|
||||
eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check included event kinds (if specified)
|
||||
if (calendar.IncludedEventKinds is { Count: > 0 } &&
|
||||
!calendar.IncludedEventKinds.Any(k =>
|
||||
eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var schedule in calendar.Schedules.Where(s => s.Enabled))
|
||||
{
|
||||
var result = EvaluateSchedule(calendar, schedule, now);
|
||||
if (result.IsActive)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Quiet hours active for {EventKind}: calendar={Calendar}, schedule={Schedule}.",
|
||||
eventKind, calendar.Name, schedule.Name);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(QuietHoursEvaluationResult.NotActive());
|
||||
}
|
||||
|
||||
private static QuietHoursEvaluationResult EvaluateSchedule(
|
||||
QuietHoursCalendar calendar,
|
||||
QuietHoursScheduleEntry schedule,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
// Parse times
|
||||
if (!TimeSpan.TryParse(schedule.StartTime, out var startTime) ||
|
||||
!TimeSpan.TryParse(schedule.EndTime, out var endTime))
|
||||
{
|
||||
return QuietHoursEvaluationResult.NotActive();
|
||||
}
|
||||
|
||||
// Convert to local time if timezone specified
|
||||
var localNow = now;
|
||||
TimeZoneInfo? tz = null;
|
||||
if (!string.IsNullOrWhiteSpace(schedule.Timezone))
|
||||
{
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone);
|
||||
localNow = TimeZoneInfo.ConvertTime(now, tz);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid timezone, use UTC
|
||||
}
|
||||
}
|
||||
|
||||
var currentTime = localNow.TimeOfDay;
|
||||
var currentDay = (int)localNow.DayOfWeek;
|
||||
|
||||
// Check day of week
|
||||
if (schedule.DaysOfWeek is { Count: > 0 } && !schedule.DaysOfWeek.Contains(currentDay))
|
||||
{
|
||||
return QuietHoursEvaluationResult.NotActive();
|
||||
}
|
||||
|
||||
// Check if current time is within quiet hours
|
||||
bool inQuietHours;
|
||||
DateTimeOffset endsAt;
|
||||
|
||||
if (startTime <= endTime)
|
||||
{
|
||||
// Same-day window (e.g., 09:00 to 17:00)
|
||||
inQuietHours = currentTime >= startTime && currentTime < endTime;
|
||||
endsAt = localNow.Date + endTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overnight window (e.g., 22:00 to 08:00)
|
||||
inQuietHours = currentTime >= startTime || currentTime < endTime;
|
||||
endsAt = currentTime >= startTime
|
||||
? localNow.Date.AddDays(1) + endTime
|
||||
: localNow.Date + endTime;
|
||||
}
|
||||
|
||||
if (inQuietHours)
|
||||
{
|
||||
// Convert back to UTC if needed
|
||||
if (tz is not null)
|
||||
{
|
||||
endsAt = TimeZoneInfo.ConvertTimeToUtc(endsAt.DateTime, tz);
|
||||
}
|
||||
|
||||
return QuietHoursEvaluationResult.Active(
|
||||
calendar.CalendarId,
|
||||
calendar.Name,
|
||||
schedule.Name,
|
||||
endsAt);
|
||||
}
|
||||
|
||||
return QuietHoursEvaluationResult.NotActive();
|
||||
}
|
||||
|
||||
private static string BuildKey(string tenantId, string calendarId) =>
|
||||
$"{tenantId}:{calendarId}";
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates quiet hours and maintenance windows for notification suppression.
|
||||
/// </summary>
|
||||
public interface IQuietHoursEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if notifications should be suppressed for the given tenant/event.
|
||||
/// </summary>
|
||||
Task<SuppressionCheckResult> EvaluateAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a maintenance window.
|
||||
/// </summary>
|
||||
Task AddMaintenanceWindowAsync(
|
||||
string tenantId,
|
||||
MaintenanceWindow window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a maintenance window.
|
||||
/// </summary>
|
||||
Task RemoveMaintenanceWindowAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists active maintenance windows.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MaintenanceWindow>> ListMaintenanceWindowsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scheduled maintenance window.
|
||||
/// </summary>
|
||||
public sealed record MaintenanceWindow
|
||||
{
|
||||
public required string WindowId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset StartTime { get; init; }
|
||||
public required DateTimeOffset EndTime { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? AffectedEventKinds { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration.
|
||||
/// </summary>
|
||||
public sealed record QuietHoursSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether quiet hours are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start time (local time, 24h format, e.g., "22:00").
|
||||
/// </summary>
|
||||
public string? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End time (local time, 24h format, e.g., "08:00").
|
||||
/// </summary>
|
||||
public string? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days of week when quiet hours apply (0=Sunday, 6=Saturday).
|
||||
/// </summary>
|
||||
public IReadOnlyList<int>? DaysOfWeek { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for the schedule (IANA format, e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string? Timezone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kinds to exclude from quiet hours (always notify).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ExcludedEventKinds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IQuietHoursEvaluator"/>.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluator : IQuietHoursEvaluator
|
||||
{
|
||||
private readonly Dictionary<string, List<MaintenanceWindow>> _maintenanceWindows = new();
|
||||
private readonly QuietHoursOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<QuietHoursEvaluator> _logger;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public QuietHoursEvaluator(
|
||||
IOptions<QuietHoursOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<QuietHoursEvaluator> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<SuppressionCheckResult> EvaluateAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check maintenance windows first
|
||||
var maintenanceResult = CheckMaintenanceWindows(tenantId, eventKind, now);
|
||||
if (maintenanceResult.IsSuppressed)
|
||||
{
|
||||
return Task.FromResult(maintenanceResult);
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
var quietHoursResult = CheckQuietHours(tenantId, eventKind, now);
|
||||
return Task.FromResult(quietHoursResult);
|
||||
}
|
||||
|
||||
public Task AddMaintenanceWindowAsync(
|
||||
string tenantId,
|
||||
MaintenanceWindow window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_maintenanceWindows.TryGetValue(tenantId, out var windows))
|
||||
{
|
||||
windows = [];
|
||||
_maintenanceWindows[tenantId] = windows;
|
||||
}
|
||||
|
||||
windows.Add(window);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added maintenance window {WindowId} for tenant {TenantId}: {Start} to {End}.",
|
||||
window.WindowId, tenantId, window.StartTime, window.EndTime);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveMaintenanceWindowAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_maintenanceWindows.TryGetValue(tenantId, out var windows))
|
||||
{
|
||||
windows.RemoveAll(w => w.WindowId == windowId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removed maintenance window {WindowId} for tenant {TenantId}.",
|
||||
windowId, tenantId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MaintenanceWindow>> ListMaintenanceWindowsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_maintenanceWindows.TryGetValue(tenantId, out var windows))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var active = windows.Where(w => w.EndTime > now).ToList();
|
||||
return Task.FromResult<IReadOnlyList<MaintenanceWindow>>(active);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MaintenanceWindow>>([]);
|
||||
}
|
||||
|
||||
private SuppressionCheckResult CheckMaintenanceWindows(string tenantId, string eventKind, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_maintenanceWindows.TryGetValue(tenantId, out var windows))
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
foreach (var window in windows)
|
||||
{
|
||||
if (now >= window.StartTime && now < window.EndTime)
|
||||
{
|
||||
// Check if this event kind is affected
|
||||
if (window.AffectedEventKinds is null ||
|
||||
window.AffectedEventKinds.Count == 0 ||
|
||||
window.AffectedEventKinds.Any(k => eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventKind} suppressed by maintenance window {WindowId}.",
|
||||
eventKind, window.WindowId);
|
||||
|
||||
return SuppressionCheckResult.Suppressed(
|
||||
window.Description ?? $"Maintenance window: {window.WindowId}",
|
||||
"maintenance",
|
||||
window.EndTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
private SuppressionCheckResult CheckQuietHours(string tenantId, string eventKind, DateTimeOffset now)
|
||||
{
|
||||
if (!_options.Enabled || _options.Schedule is null)
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
var schedule = _options.Schedule;
|
||||
if (!schedule.Enabled)
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
// Check excluded event kinds
|
||||
if (schedule.ExcludedEventKinds?.Any(k =>
|
||||
eventKind.StartsWith(k, StringComparison.OrdinalIgnoreCase)) == true)
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
// Parse times
|
||||
if (!TryParseTime(schedule.StartTime, out var startTime) ||
|
||||
!TryParseTime(schedule.EndTime, out var endTime))
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
// Convert to local time if timezone specified
|
||||
var localNow = now;
|
||||
if (!string.IsNullOrWhiteSpace(schedule.Timezone))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone);
|
||||
localNow = TimeZoneInfo.ConvertTime(now, tz);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid timezone, use UTC
|
||||
}
|
||||
}
|
||||
|
||||
var currentTime = localNow.TimeOfDay;
|
||||
var currentDay = (int)localNow.DayOfWeek;
|
||||
|
||||
// Check day of week
|
||||
if (schedule.DaysOfWeek is { Count: > 0 } && !schedule.DaysOfWeek.Contains(currentDay))
|
||||
{
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
// Check if current time is within quiet hours
|
||||
bool inQuietHours;
|
||||
DateTimeOffset suppressedUntil;
|
||||
|
||||
if (startTime <= endTime)
|
||||
{
|
||||
// Same-day window (e.g., 09:00 to 17:00)
|
||||
inQuietHours = currentTime >= startTime && currentTime < endTime;
|
||||
suppressedUntil = localNow.Date + endTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overnight window (e.g., 22:00 to 08:00)
|
||||
inQuietHours = currentTime >= startTime || currentTime < endTime;
|
||||
suppressedUntil = currentTime >= startTime
|
||||
? localNow.Date.AddDays(1) + endTime
|
||||
: localNow.Date + endTime;
|
||||
}
|
||||
|
||||
if (inQuietHours)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventKind} suppressed by quiet hours until {Until}.",
|
||||
eventKind, suppressedUntil);
|
||||
|
||||
return SuppressionCheckResult.Suppressed(
|
||||
"Quiet hours active",
|
||||
"quiet_hours",
|
||||
suppressedUntil);
|
||||
}
|
||||
|
||||
return SuppressionCheckResult.NotSuppressed();
|
||||
}
|
||||
|
||||
private static bool TryParseTime(string? timeString, out TimeSpan time)
|
||||
{
|
||||
time = TimeSpan.Zero;
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TimeSpan.TryParse(timeString, out time);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for quiet hours.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:QuietHours";
|
||||
|
||||
/// <summary>
|
||||
/// Whether quiet hours evaluation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default quiet hours schedule.
|
||||
/// </summary>
|
||||
public QuietHoursSchedule? Schedule { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering quiet hours and throttle services.
|
||||
/// </summary>
|
||||
public static class QuietHoursServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds quiet hours calendar and throttle configuration services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddQuietHoursServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Quiet hours calendar service
|
||||
services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
|
||||
|
||||
// Throttle configuration service
|
||||
services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISuppressionAuditLogger"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemorySuppressionAuditLogger : ISuppressionAuditLogger
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<SuppressionAuditEntry>> _entries = new();
|
||||
private readonly SuppressionAuditOptions _options;
|
||||
private readonly ILogger<InMemorySuppressionAuditLogger> _logger;
|
||||
|
||||
public InMemorySuppressionAuditLogger(
|
||||
IOptions<SuppressionAuditOptions> options,
|
||||
ILogger<InMemorySuppressionAuditLogger> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task LogAsync(SuppressionAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantEntries = _entries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<SuppressionAuditEntry>());
|
||||
tenantEntries.Enqueue(entry);
|
||||
|
||||
// Trim to retention limit
|
||||
while (tenantEntries.Count > _options.MaxEntriesPerTenant)
|
||||
{
|
||||
tenantEntries.TryDequeue(out _);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Audit: {Action} on {ResourceType}/{ResourceId} by {Actor} for tenant {TenantId}.",
|
||||
entry.Action, entry.ResourceType, entry.ResourceId, entry.Actor, entry.TenantId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SuppressionAuditEntry>> QueryAsync(
|
||||
SuppressionAuditQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_entries.TryGetValue(query.TenantId, out var tenantEntries))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SuppressionAuditEntry>>([]);
|
||||
}
|
||||
|
||||
var entries = tenantEntries.AsEnumerable();
|
||||
|
||||
// Apply filters
|
||||
if (query.From.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp >= query.From.Value);
|
||||
}
|
||||
|
||||
if (query.To.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp <= query.To.Value);
|
||||
}
|
||||
|
||||
if (query.Actions is { Count: > 0 })
|
||||
{
|
||||
entries = entries.Where(e => query.Actions.Contains(e.Action));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Actor))
|
||||
{
|
||||
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ResourceType))
|
||||
{
|
||||
entries = entries.Where(e => e.ResourceType.Equals(query.ResourceType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ResourceId))
|
||||
{
|
||||
entries = entries.Where(e => e.ResourceId.Equals(query.ResourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Order by timestamp descending, apply pagination
|
||||
var result = entries
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SuppressionAuditEntry>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for suppression audit logging.
|
||||
/// </summary>
|
||||
public sealed class SuppressionAuditOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:SuppressionAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum audit entries to retain per tenant.
|
||||
/// </summary>
|
||||
public int MaxEntriesPerTenant { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for audit entries.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to log to structured logging in addition to storage.
|
||||
/// </summary>
|
||||
public bool LogToStructuredLogs { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IThrottleConfigService"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryThrottleConfigService : IThrottleConfigService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TenantThrottleConfig> _tenantConfigs = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, EventKindThrottleConfig>> _eventKindConfigs = new();
|
||||
private readonly ISuppressionAuditLogger _auditLogger;
|
||||
private readonly ThrottlerOptions _globalOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryThrottleConfigService> _logger;
|
||||
|
||||
public InMemoryThrottleConfigService(
|
||||
ISuppressionAuditLogger auditLogger,
|
||||
IOptions<ThrottlerOptions> globalOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryThrottleConfigService> logger)
|
||||
{
|
||||
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
|
||||
_globalOptions = globalOptions?.Value ?? throw new ArgumentNullException(nameof(globalOptions));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<EffectiveThrottleConfig> GetEffectiveConfigAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Start with global defaults
|
||||
var window = _globalOptions.DefaultWindow;
|
||||
var maxEvents = _globalOptions.DefaultMaxEvents;
|
||||
var enabled = _globalOptions.Enabled;
|
||||
var source = "global";
|
||||
string? matchedPattern = null;
|
||||
var burstAllowance = 0;
|
||||
TimeSpan? cooldownPeriod = null;
|
||||
|
||||
// Override with tenant defaults if available
|
||||
if (_tenantConfigs.TryGetValue(tenantId, out var tenantConfig))
|
||||
{
|
||||
enabled = tenantConfig.Enabled;
|
||||
window = tenantConfig.DefaultWindow;
|
||||
maxEvents = tenantConfig.DefaultMaxEvents;
|
||||
burstAllowance = tenantConfig.BurstAllowance;
|
||||
cooldownPeriod = tenantConfig.CooldownPeriod;
|
||||
source = "tenant";
|
||||
}
|
||||
|
||||
// Override with event kind config if available
|
||||
if (_eventKindConfigs.TryGetValue(tenantId, out var eventKindConfigs))
|
||||
{
|
||||
var matchingConfig = FindMatchingEventKindConfig(eventKind, eventKindConfigs.Values);
|
||||
if (matchingConfig is not null)
|
||||
{
|
||||
if (matchingConfig.Enabled)
|
||||
{
|
||||
enabled = true;
|
||||
window = matchingConfig.Window ?? window;
|
||||
maxEvents = matchingConfig.MaxEvents ?? maxEvents;
|
||||
burstAllowance = matchingConfig.BurstAllowance ?? burstAllowance;
|
||||
cooldownPeriod = matchingConfig.CooldownPeriod ?? cooldownPeriod;
|
||||
}
|
||||
else
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
source = "event_kind";
|
||||
matchedPattern = matchingConfig.EventKindPattern;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new EffectiveThrottleConfig
|
||||
{
|
||||
Window = window,
|
||||
MaxEvents = maxEvents,
|
||||
Enabled = enabled,
|
||||
Source = source,
|
||||
MatchedPattern = matchedPattern,
|
||||
BurstAllowance = burstAllowance,
|
||||
CooldownPeriod = cooldownPeriod
|
||||
});
|
||||
}
|
||||
|
||||
public Task<TenantThrottleConfig?> GetTenantConfigAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantConfigs.TryGetValue(tenantId, out var config);
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
|
||||
public async Task<TenantThrottleConfig> SetTenantConfigAsync(
|
||||
string tenantId,
|
||||
TenantThrottleConfigUpdate update,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var changes = new Dictionary<string, object>();
|
||||
|
||||
var config = _tenantConfigs.AddOrUpdate(
|
||||
tenantId,
|
||||
_ =>
|
||||
{
|
||||
changes["action"] = "created";
|
||||
return new TenantThrottleConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Enabled = update.Enabled ?? true,
|
||||
DefaultWindow = update.DefaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
DefaultMaxEvents = update.DefaultMaxEvents ?? 10,
|
||||
BurstAllowance = update.BurstAllowance ?? 0,
|
||||
CooldownPeriod = update.CooldownPeriod,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
},
|
||||
(_, existing) =>
|
||||
{
|
||||
changes["action"] = "updated";
|
||||
if (update.Enabled.HasValue && update.Enabled != existing.Enabled)
|
||||
changes["enabled"] = new { from = existing.Enabled, to = update.Enabled };
|
||||
if (update.DefaultWindow.HasValue && update.DefaultWindow != existing.DefaultWindow)
|
||||
changes["defaultWindow"] = new { from = existing.DefaultWindow, to = update.DefaultWindow };
|
||||
if (update.DefaultMaxEvents.HasValue && update.DefaultMaxEvents != existing.DefaultMaxEvents)
|
||||
changes["defaultMaxEvents"] = new { from = existing.DefaultMaxEvents, to = update.DefaultMaxEvents };
|
||||
|
||||
return existing with
|
||||
{
|
||||
Enabled = update.Enabled ?? existing.Enabled,
|
||||
DefaultWindow = update.DefaultWindow ?? existing.DefaultWindow,
|
||||
DefaultMaxEvents = update.DefaultMaxEvents ?? existing.DefaultMaxEvents,
|
||||
BurstAllowance = update.BurstAllowance ?? existing.BurstAllowance,
|
||||
CooldownPeriod = update.CooldownPeriod ?? existing.CooldownPeriod,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Set tenant throttle config for {TenantId} by {Actor}: enabled={Enabled}, window={Window}, max={Max}.",
|
||||
tenantId, actor, config.Enabled, config.DefaultWindow, config.DefaultMaxEvents);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.ThrottleConfigUpdated,
|
||||
ResourceType = "TenantThrottleConfig",
|
||||
ResourceId = tenantId,
|
||||
Details = changes
|
||||
}, cancellationToken);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EventKindThrottleConfig>> ListEventKindConfigsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_eventKindConfigs.TryGetValue(tenantId, out var configs))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<EventKindThrottleConfig>>([]);
|
||||
}
|
||||
|
||||
var list = configs.Values
|
||||
.OrderBy(c => c.Priority)
|
||||
.ThenBy(c => c.EventKindPattern)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EventKindThrottleConfig>>(list);
|
||||
}
|
||||
|
||||
public async Task<EventKindThrottleConfig> SetEventKindConfigAsync(
|
||||
string tenantId,
|
||||
string eventKindPattern,
|
||||
EventKindThrottleConfigUpdate update,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var changes = new Dictionary<string, object> { ["pattern"] = eventKindPattern };
|
||||
|
||||
var tenantConfigs = _eventKindConfigs.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, EventKindThrottleConfig>());
|
||||
|
||||
var config = tenantConfigs.AddOrUpdate(
|
||||
eventKindPattern,
|
||||
_ =>
|
||||
{
|
||||
changes["action"] = "created";
|
||||
return new EventKindThrottleConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
EventKindPattern = eventKindPattern,
|
||||
Enabled = update.Enabled ?? true,
|
||||
Window = update.Window,
|
||||
MaxEvents = update.MaxEvents,
|
||||
BurstAllowance = update.BurstAllowance,
|
||||
CooldownPeriod = update.CooldownPeriod,
|
||||
Priority = update.Priority ?? 100,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
},
|
||||
(_, existing) =>
|
||||
{
|
||||
changes["action"] = "updated";
|
||||
if (update.Enabled.HasValue && update.Enabled != existing.Enabled)
|
||||
changes["enabled"] = new { from = existing.Enabled, to = update.Enabled };
|
||||
if (update.MaxEvents.HasValue && update.MaxEvents != existing.MaxEvents)
|
||||
changes["maxEvents"] = new { from = existing.MaxEvents, to = update.MaxEvents };
|
||||
|
||||
return existing with
|
||||
{
|
||||
Enabled = update.Enabled ?? existing.Enabled,
|
||||
Window = update.Window ?? existing.Window,
|
||||
MaxEvents = update.MaxEvents ?? existing.MaxEvents,
|
||||
BurstAllowance = update.BurstAllowance ?? existing.BurstAllowance,
|
||||
CooldownPeriod = update.CooldownPeriod ?? existing.CooldownPeriod,
|
||||
Priority = update.Priority ?? existing.Priority,
|
||||
UpdatedBy = actor,
|
||||
UpdatedAt = now
|
||||
};
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Set event kind throttle config for {TenantId} pattern '{Pattern}' by {Actor}.",
|
||||
tenantId, eventKindPattern, actor);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.ThrottleConfigUpdated,
|
||||
ResourceType = "EventKindThrottleConfig",
|
||||
ResourceId = eventKindPattern,
|
||||
Details = changes
|
||||
}, cancellationToken);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveEventKindConfigAsync(
|
||||
string tenantId,
|
||||
string eventKindPattern,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_eventKindConfigs.TryGetValue(tenantId, out var configs))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!configs.TryRemove(eventKindPattern, out var removed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removed event kind throttle config for {TenantId} pattern '{Pattern}' by {Actor}.",
|
||||
tenantId, eventKindPattern, actor);
|
||||
|
||||
await _auditLogger.LogAsync(new SuppressionAuditEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Timestamp = now,
|
||||
Actor = actor,
|
||||
Action = SuppressionAuditAction.ThrottleConfigDeleted,
|
||||
ResourceType = "EventKindThrottleConfig",
|
||||
ResourceId = eventKindPattern,
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["removedConfig"] = new
|
||||
{
|
||||
removed.Enabled,
|
||||
removed.Window,
|
||||
removed.MaxEvents
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static EventKindThrottleConfig? FindMatchingEventKindConfig(
|
||||
string eventKind,
|
||||
ICollection<EventKindThrottleConfig> configs)
|
||||
{
|
||||
return configs
|
||||
.Where(c => MatchesPattern(eventKind, c.EventKindPattern))
|
||||
.OrderBy(c => c.Priority)
|
||||
.ThenByDescending(c => c.EventKindPattern.Length) // More specific patterns first
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string eventKind, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.EndsWith('*'))
|
||||
{
|
||||
var prefix = pattern[..^1];
|
||||
return eventKind.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return eventKind.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing per-tenant throttle configurations.
|
||||
/// </summary>
|
||||
public interface IThrottleConfigurationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<ThrottleConfiguration?> GetConfigurationAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<ThrottleConfiguration> UpsertConfigurationAsync(
|
||||
ThrottleConfiguration configuration,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the throttle configuration for a tenant (reverts to defaults).
|
||||
/// </summary>
|
||||
Task<bool> DeleteConfigurationAsync(
|
||||
string tenantId,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective throttle duration for an event kind.
|
||||
/// </summary>
|
||||
Task<TimeSpan> GetEffectiveThrottleDurationAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public sealed record ThrottleConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle duration for all event kinds.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultDuration { get; init; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Event kind specific overrides (key = event kind prefix, value = duration).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, TimeSpan>? EventKindOverrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum events per window for burst limiting.
|
||||
/// </summary>
|
||||
public int? MaxEventsPerWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Window duration for burst limiting.
|
||||
/// </summary>
|
||||
public TimeSpan? BurstWindowDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether throttling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who last updated.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of throttle configuration service.
|
||||
/// </summary>
|
||||
public sealed class InMemoryThrottleConfigurationService : IThrottleConfigurationService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ThrottleConfiguration> _configurations = new();
|
||||
private readonly INotifyAuditRepository? _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryThrottleConfigurationService> _logger;
|
||||
|
||||
private static readonly TimeSpan DefaultThrottleDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
public InMemoryThrottleConfigurationService(
|
||||
INotifyAuditRepository? auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryThrottleConfigurationService> logger)
|
||||
{
|
||||
_auditRepository = auditRepository;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<ThrottleConfiguration?> GetConfigurationAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_configurations.TryGetValue(tenantId, out var configuration);
|
||||
return Task.FromResult(configuration);
|
||||
}
|
||||
|
||||
public async Task<ThrottleConfiguration> UpsertConfigurationAsync(
|
||||
ThrottleConfiguration configuration,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var isNew = !_configurations.ContainsKey(configuration.TenantId);
|
||||
|
||||
var updated = configuration with
|
||||
{
|
||||
CreatedAt = isNew ? now : configuration.CreatedAt,
|
||||
CreatedBy = isNew ? actor : configuration.CreatedBy,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
_configurations[configuration.TenantId] = updated;
|
||||
|
||||
// Audit
|
||||
if (_auditRepository is not null)
|
||||
{
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
["defaultDuration"] = configuration.DefaultDuration.TotalSeconds.ToString(),
|
||||
["enabled"] = configuration.Enabled.ToString(),
|
||||
["overrideCount"] = (configuration.EventKindOverrides?.Count ?? 0).ToString()
|
||||
};
|
||||
|
||||
if (configuration.MaxEventsPerWindow.HasValue)
|
||||
{
|
||||
payload["maxEventsPerWindow"] = configuration.MaxEventsPerWindow.Value.ToString();
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
configuration.TenantId,
|
||||
isNew ? "throttle_config_created" : "throttle_config_updated",
|
||||
payload,
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"{Action} throttle configuration for tenant {TenantId}: default={DefaultDuration}s, overrides={OverrideCount}",
|
||||
isNew ? "Created" : "Updated",
|
||||
configuration.TenantId,
|
||||
configuration.DefaultDuration.TotalSeconds,
|
||||
configuration.EventKindOverrides?.Count ?? 0);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteConfigurationAsync(
|
||||
string tenantId,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var removed = _configurations.TryRemove(tenantId, out _);
|
||||
|
||||
if (removed && _auditRepository is not null)
|
||||
{
|
||||
await _auditRepository.AppendAsync(
|
||||
tenantId,
|
||||
"throttle_config_deleted",
|
||||
new Dictionary<string, string>(),
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleted throttle configuration for tenant {TenantId}.",
|
||||
tenantId);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> GetEffectiveThrottleDurationAsync(
|
||||
string tenantId,
|
||||
string eventKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = await GetConfigurationAsync(tenantId, cancellationToken);
|
||||
|
||||
if (config is null || !config.Enabled)
|
||||
{
|
||||
return DefaultThrottleDuration;
|
||||
}
|
||||
|
||||
// Check for event kind specific overrides
|
||||
if (config.EventKindOverrides is { Count: > 0 })
|
||||
{
|
||||
// Find the most specific match (longest prefix)
|
||||
var matchingOverride = config.EventKindOverrides
|
||||
.Where(kvp => eventKind.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(kvp => kvp.Key.Length)
|
||||
.Select(kvp => (TimeSpan?)kvp.Value)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingOverride.HasValue)
|
||||
{
|
||||
return matchingOverride.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return config.DefaultDuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Distributes generated digests to recipients.
|
||||
/// </summary>
|
||||
public interface IDigestDistributor
|
||||
{
|
||||
/// <summary>
|
||||
/// Distributes a digest to the specified recipients.
|
||||
/// </summary>
|
||||
Task<DigestDistributionResult> DistributeAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestFormat format,
|
||||
IReadOnlyList<DigestRecipient> recipients,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of digest distribution.
|
||||
/// </summary>
|
||||
public sealed record DigestDistributionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Total recipients attempted.
|
||||
/// </summary>
|
||||
public int TotalRecipients { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Successfully delivered count.
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failed delivery count.
|
||||
/// </summary>
|
||||
public int FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery results.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RecipientDeliveryResult> Results { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of delivery to a single recipient.
|
||||
/// </summary>
|
||||
public sealed record RecipientDeliveryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Recipient address.
|
||||
/// </summary>
|
||||
public required string Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether delivery succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When delivery was attempted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttemptedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDigestDistributor"/>.
|
||||
/// </summary>
|
||||
public sealed class DigestDistributor : IDigestDistributor
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly DigestDistributorOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DigestDistributor> _logger;
|
||||
|
||||
public DigestDistributor(
|
||||
HttpClient httpClient,
|
||||
IOptions<DigestDistributorOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DigestDistributor> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DigestDistributionResult> DistributeAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestFormat format,
|
||||
IReadOnlyList<DigestRecipient> recipients,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(renderedContent);
|
||||
ArgumentNullException.ThrowIfNull(recipients);
|
||||
|
||||
var results = new List<RecipientDeliveryResult>();
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var result = await DeliverToRecipientAsync(
|
||||
content,
|
||||
renderedContent,
|
||||
format,
|
||||
recipient,
|
||||
cancellationToken);
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
var successCount = results.Count(r => r.Success);
|
||||
var failureCount = results.Count(r => !r.Success);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Distributed digest {DigestId}: {Success}/{Total} successful.",
|
||||
content.DigestId, successCount, recipients.Count);
|
||||
|
||||
return new DigestDistributionResult
|
||||
{
|
||||
TotalRecipients = recipients.Count,
|
||||
SuccessCount = successCount,
|
||||
FailureCount = failureCount,
|
||||
Results = results
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RecipientDeliveryResult> DeliverToRecipientAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestFormat format,
|
||||
DigestRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attemptedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var success = recipient.Type.ToLowerInvariant() switch
|
||||
{
|
||||
"webhook" => await DeliverToWebhookAsync(content, renderedContent, format, recipient, cancellationToken),
|
||||
"slack" => await DeliverToSlackAsync(content, renderedContent, recipient, cancellationToken),
|
||||
"teams" => await DeliverToTeamsAsync(content, renderedContent, recipient, cancellationToken),
|
||||
"email" => await DeliverToEmailAsync(content, renderedContent, format, recipient, cancellationToken),
|
||||
_ => throw new NotSupportedException($"Recipient type '{recipient.Type}' is not supported.")
|
||||
};
|
||||
|
||||
return new RecipientDeliveryResult
|
||||
{
|
||||
Address = recipient.Address,
|
||||
Type = recipient.Type,
|
||||
Success = success,
|
||||
AttemptedAt = attemptedAt
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to deliver digest {DigestId} to {Type}:{Address}.",
|
||||
content.DigestId, recipient.Type, recipient.Address);
|
||||
|
||||
return new RecipientDeliveryResult
|
||||
{
|
||||
Address = recipient.Address,
|
||||
Type = recipient.Type,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
AttemptedAt = attemptedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DeliverToWebhookAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestFormat format,
|
||||
DigestRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
digestId = content.DigestId,
|
||||
tenantId = content.TenantId,
|
||||
title = content.Title,
|
||||
periodStart = content.PeriodStart,
|
||||
periodEnd = content.PeriodEnd,
|
||||
generatedAt = content.GeneratedAt,
|
||||
format = format.ToString().ToLowerInvariant(),
|
||||
content = renderedContent,
|
||||
summary = content.Summary
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
recipient.Address,
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task<bool> DeliverToSlackAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build Slack blocks
|
||||
var blocks = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "header",
|
||||
text = new { type = "plain_text", text = content.Title }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new object[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*Total Incidents:*\n{content.Summary.TotalIncidents}" },
|
||||
new { type = "mrkdwn", text = $"*New:*\n{content.Summary.NewIncidents}" },
|
||||
new { type = "mrkdwn", text = $"*Acknowledged:*\n{content.Summary.AcknowledgedIncidents}" },
|
||||
new { type = "mrkdwn", text = $"*Resolved:*\n{content.Summary.ResolvedIncidents}" }
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "divider"
|
||||
}
|
||||
};
|
||||
|
||||
// Add top incidents
|
||||
foreach (var incident in content.Incidents.Take(5))
|
||||
{
|
||||
var statusEmoji = incident.Status switch
|
||||
{
|
||||
Correlation.IncidentStatus.Open => ":red_circle:",
|
||||
Correlation.IncidentStatus.Acknowledged => ":large_yellow_circle:",
|
||||
Correlation.IncidentStatus.Resolved => ":large_green_circle:",
|
||||
_ => ":white_circle:"
|
||||
};
|
||||
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"{statusEmoji} *{incident.Title}*\n_{incident.EventKind}_ • {incident.EventCount} events"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (content.Incidents.Count > 5)
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"_...and {content.Incidents.Count - 5} more incidents_" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new { blocks };
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(recipient.Address, httpContent, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task<bool> DeliverToTeamsAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build Teams Adaptive Card
|
||||
var card = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
contentUrl = (string?)null,
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = content.Title,
|
||||
weight = "Bolder",
|
||||
size = "Large"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "ColumnSet",
|
||||
columns = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "Column",
|
||||
width = "auto",
|
||||
items = new object[]
|
||||
{
|
||||
new { type = "TextBlock", text = "Total", weight = "Bolder" },
|
||||
new { type = "TextBlock", text = content.Summary.TotalIncidents.ToString() }
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "Column",
|
||||
width = "auto",
|
||||
items = new object[]
|
||||
{
|
||||
new { type = "TextBlock", text = "New", weight = "Bolder" },
|
||||
new { type = "TextBlock", text = content.Summary.NewIncidents.ToString() }
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "Column",
|
||||
width = "auto",
|
||||
items = new object[]
|
||||
{
|
||||
new { type = "TextBlock", text = "Resolved", weight = "Bolder" },
|
||||
new { type = "TextBlock", text = content.Summary.ResolvedIncidents.ToString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = $"Period: {content.PeriodStart:yyyy-MM-dd} to {content.PeriodEnd:yyyy-MM-dd}",
|
||||
isSubtle = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(card);
|
||||
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(recipient.Address, httpContent, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private Task<bool> DeliverToEmailAsync(
|
||||
DigestContent content,
|
||||
string renderedContent,
|
||||
DigestFormat format,
|
||||
DigestRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Email delivery would typically use an email service
|
||||
// For now, log and return success (actual implementation would integrate with email adapter)
|
||||
_logger.LogInformation(
|
||||
"Email delivery for digest {DigestId} to {Address} would be sent here.",
|
||||
content.DigestId, recipient.Address);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Use an IEmailSender or similar service
|
||||
// 2. Format the content appropriately (HTML for HTML format, etc.)
|
||||
// 3. Send via SMTP or email API
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for digest distribution.
|
||||
/// </summary>
|
||||
public sealed class DigestDistributorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:DigestDistributor";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for HTTP delivery requests.
|
||||
/// </summary>
|
||||
public TimeSpan DeliveryTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts per recipient.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to continue on individual delivery failures.
|
||||
/// </summary>
|
||||
public bool ContinueOnFailure { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDigestGenerator"/>.
|
||||
/// </summary>
|
||||
public sealed class DigestGenerator : IDigestGenerator
|
||||
{
|
||||
private readonly IIncidentManager _incidentManager;
|
||||
private readonly DigestOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DigestGenerator> _logger;
|
||||
|
||||
public DigestGenerator(
|
||||
IIncidentManager incidentManager,
|
||||
IOptions<DigestOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DigestGenerator> logger)
|
||||
{
|
||||
_incidentManager = incidentManager ?? throw new ArgumentNullException(nameof(incidentManager));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DigestResult> GenerateAsync(
|
||||
string tenantId,
|
||||
DigestQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generating digest for tenant {TenantId} from {From} to {To}.",
|
||||
tenantId, query.From, query.To);
|
||||
|
||||
var result = await BuildDigestAsync(tenantId, query, isPreview: false, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated digest {DigestId} for tenant {TenantId}: {IncidentCount} incidents, {EventCount} total events.",
|
||||
result.DigestId, tenantId, result.TotalIncidentCount, result.Summary.TotalEvents);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<DigestResult> PreviewAsync(
|
||||
string tenantId,
|
||||
DigestQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Previewing digest for tenant {TenantId} from {From} to {To}.",
|
||||
tenantId, query.From, query.To);
|
||||
|
||||
return await BuildDigestAsync(tenantId, query, isPreview: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DigestResult> BuildDigestAsync(
|
||||
string tenantId,
|
||||
DigestQuery query,
|
||||
bool isPreview,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get all incidents for the tenant (we'll filter by time window)
|
||||
var allIncidents = await _incidentManager.ListAsync(
|
||||
tenantId,
|
||||
statusFilter: null,
|
||||
limit: _options.MaxIncidentsPerDigest * 2, // fetch extra to handle filtering
|
||||
cancellationToken);
|
||||
|
||||
// Filter incidents that have activity within the time window
|
||||
var incidentsInWindow = allIncidents
|
||||
.Where(i => IsInTimeWindow(i, query.From, query.To))
|
||||
.Where(i => query.IncludeResolved || i.Status != IncidentStatus.Resolved)
|
||||
.Where(i => query.EventKinds is null || query.EventKinds.Count == 0 || query.EventKinds.Contains(i.EventKind, StringComparer.OrdinalIgnoreCase))
|
||||
.Where(i => query.IncidentStatuses is null || query.IncidentStatuses.Count == 0 || query.IncidentStatuses.Contains(i.Status))
|
||||
.OrderByDescending(i => i.LastOccurrence)
|
||||
.ToList();
|
||||
|
||||
var totalCount = incidentsInWindow.Count;
|
||||
var truncatedIncidents = incidentsInWindow.Take(query.MaxIncidents).ToList();
|
||||
|
||||
// Build summary statistics
|
||||
var summary = BuildSummary(incidentsInWindow, query.From, query.To);
|
||||
|
||||
// Convert to digest incidents
|
||||
var digestIncidents = truncatedIncidents
|
||||
.Select(DigestIncident.FromIncidentState)
|
||||
.ToList();
|
||||
|
||||
var result = new DigestResult
|
||||
{
|
||||
DigestId = $"dgst-{Guid.NewGuid():N}"[..20],
|
||||
TenantId = tenantId,
|
||||
From = query.From,
|
||||
To = query.To,
|
||||
GeneratedAt = now,
|
||||
Summary = summary,
|
||||
Incidents = digestIncidents,
|
||||
HasMore = totalCount > query.MaxIncidents,
|
||||
TotalIncidentCount = totalCount,
|
||||
IsPreview = isPreview,
|
||||
ScheduleName = query.ScheduleName
|
||||
};
|
||||
|
||||
// Render content if enabled
|
||||
if (_options.RenderContent)
|
||||
{
|
||||
result = result with { Content = RenderContent(result) };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsInTimeWindow(IncidentState incident, DateTimeOffset from, DateTimeOffset to)
|
||||
{
|
||||
// Include if any activity occurred within the window
|
||||
return incident.FirstOccurrence < to && incident.LastOccurrence >= from;
|
||||
}
|
||||
|
||||
private DigestSummary BuildSummary(IReadOnlyList<IncidentState> incidents, DateTimeOffset from, DateTimeOffset to)
|
||||
{
|
||||
var totalEvents = incidents.Sum(i => i.EventCount);
|
||||
var newIncidents = incidents.Count(i => i.FirstOccurrence >= from && i.FirstOccurrence < to);
|
||||
var resolvedIncidents = incidents.Count(i => i.Status == IncidentStatus.Resolved && i.ResolvedAt >= from && i.ResolvedAt < to);
|
||||
var acknowledgedIncidents = incidents.Count(i => i.Status == IncidentStatus.Acknowledged || (i.AcknowledgedAt >= from && i.AcknowledgedAt < to));
|
||||
var openIncidents = incidents.Count(i => i.Status == IncidentStatus.Open);
|
||||
|
||||
// Group by event kind
|
||||
var byEventKind = incidents
|
||||
.GroupBy(i => i.EventKind, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(i => i.EventCount), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Find top affected (by correlation key pattern)
|
||||
var topAffected = incidents
|
||||
.Where(i => !string.IsNullOrEmpty(i.CorrelationKey))
|
||||
.GroupBy(i => ExtractAffectedName(i.CorrelationKey!))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(_options.TopAffectedCount)
|
||||
.Select(g => new DigestTopItem(g.Key, "correlation", g.Count()))
|
||||
.ToList();
|
||||
|
||||
return new DigestSummary
|
||||
{
|
||||
TotalEvents = totalEvents,
|
||||
NewIncidents = newIncidents,
|
||||
ResolvedIncidents = resolvedIncidents,
|
||||
AcknowledgedIncidents = acknowledgedIncidents,
|
||||
OpenIncidents = openIncidents,
|
||||
ByEventKind = byEventKind,
|
||||
TopAffected = topAffected
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractAffectedName(string correlationKey)
|
||||
{
|
||||
// Extract meaningful part from correlation key (e.g., "tenant:vuln:pkg-name" -> "pkg-name")
|
||||
var parts = correlationKey.Split(':');
|
||||
return parts.Length > 2 ? parts[^1] : correlationKey;
|
||||
}
|
||||
|
||||
private DigestContent RenderContent(DigestResult digest)
|
||||
{
|
||||
return new DigestContent
|
||||
{
|
||||
PlainText = RenderPlainText(digest),
|
||||
Markdown = RenderMarkdown(digest),
|
||||
Html = RenderHtml(digest),
|
||||
Json = RenderJson(digest),
|
||||
SlackBlocks = _options.RenderSlackBlocks ? RenderSlackBlocks(digest) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string RenderPlainText(DigestResult digest)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"=== Notification Digest ===");
|
||||
sb.AppendLine($"Tenant: {digest.TenantId}");
|
||||
sb.AppendLine($"Period: {digest.From:yyyy-MM-dd HH:mm} to {digest.To:yyyy-MM-dd HH:mm} UTC");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Summary ---");
|
||||
sb.AppendLine($"Total Events: {digest.Summary.TotalEvents}");
|
||||
sb.AppendLine($"New Incidents: {digest.Summary.NewIncidents}");
|
||||
sb.AppendLine($"Resolved: {digest.Summary.ResolvedIncidents}");
|
||||
sb.AppendLine($"Acknowledged: {digest.Summary.AcknowledgedIncidents}");
|
||||
sb.AppendLine($"Open: {digest.Summary.OpenIncidents}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (digest.Summary.ByEventKind.Count > 0)
|
||||
{
|
||||
sb.AppendLine("By Event Kind:");
|
||||
foreach (var (kind, count) in digest.Summary.ByEventKind.OrderByDescending(kv => kv.Value))
|
||||
{
|
||||
sb.AppendLine($" {kind}: {count}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (digest.Incidents.Count > 0)
|
||||
{
|
||||
sb.AppendLine("--- Incidents ---");
|
||||
foreach (var incident in digest.Incidents)
|
||||
{
|
||||
sb.AppendLine($"[{incident.Status}] {incident.Title}");
|
||||
sb.AppendLine($" ID: {incident.IncidentId}");
|
||||
sb.AppendLine($" Kind: {incident.EventKind}");
|
||||
sb.AppendLine($" Events: {incident.EventCount}");
|
||||
sb.AppendLine($" First: {incident.FirstOccurrence:yyyy-MM-dd HH:mm}");
|
||||
sb.AppendLine($" Last: {incident.LastOccurrence:yyyy-MM-dd HH:mm}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (digest.HasMore)
|
||||
{
|
||||
sb.AppendLine($"... and {digest.TotalIncidentCount - digest.Incidents.Count} more incidents");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderMarkdown(DigestResult digest)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Notification Digest");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Tenant:** {digest.TenantId} ");
|
||||
sb.AppendLine($"**Period:** {digest.From:yyyy-MM-dd HH:mm} to {digest.To:yyyy-MM-dd HH:mm} UTC ");
|
||||
sb.AppendLine($"**Generated:** {digest.GeneratedAt:yyyy-MM-dd HH:mm} UTC");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Metric | Count |");
|
||||
sb.AppendLine("|--------|-------|");
|
||||
sb.AppendLine($"| Total Events | {digest.Summary.TotalEvents} |");
|
||||
sb.AppendLine($"| New Incidents | {digest.Summary.NewIncidents} |");
|
||||
sb.AppendLine($"| Resolved | {digest.Summary.ResolvedIncidents} |");
|
||||
sb.AppendLine($"| Acknowledged | {digest.Summary.AcknowledgedIncidents} |");
|
||||
sb.AppendLine($"| Open | {digest.Summary.OpenIncidents} |");
|
||||
sb.AppendLine();
|
||||
|
||||
if (digest.Incidents.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Incidents");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var incident in digest.Incidents)
|
||||
{
|
||||
var statusEmoji = incident.Status switch
|
||||
{
|
||||
IncidentStatus.Open => "🔴",
|
||||
IncidentStatus.Acknowledged => "🟡",
|
||||
IncidentStatus.Resolved => "🟢",
|
||||
_ => "⚪"
|
||||
};
|
||||
|
||||
sb.AppendLine($"### {statusEmoji} {incident.Title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **ID:** `{incident.IncidentId}`");
|
||||
sb.AppendLine($"- **Status:** {incident.Status}");
|
||||
sb.AppendLine($"- **Kind:** {incident.EventKind}");
|
||||
sb.AppendLine($"- **Events:** {incident.EventCount}");
|
||||
sb.AppendLine($"- **First Occurrence:** {incident.FirstOccurrence:yyyy-MM-dd HH:mm} UTC");
|
||||
sb.AppendLine($"- **Last Occurrence:** {incident.LastOccurrence:yyyy-MM-dd HH:mm} UTC");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (digest.HasMore)
|
||||
{
|
||||
sb.AppendLine($"*... and {digest.TotalIncidentCount - digest.Incidents.Count} more incidents*");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderHtml(DigestResult digest)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html><head><style>");
|
||||
sb.AppendLine("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }");
|
||||
sb.AppendLine("h1 { color: #1a1a1a; border-bottom: 2px solid #0066cc; padding-bottom: 10px; }");
|
||||
sb.AppendLine("table { border-collapse: collapse; width: 100%; margin: 20px 0; }");
|
||||
sb.AppendLine("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }");
|
||||
sb.AppendLine("th { background-color: #f5f5f5; }");
|
||||
sb.AppendLine(".status-open { color: #d32f2f; }");
|
||||
sb.AppendLine(".status-acknowledged { color: #f57c00; }");
|
||||
sb.AppendLine(".status-resolved { color: #388e3c; }");
|
||||
sb.AppendLine(".incident { border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
sb.AppendLine("<h1>Notification Digest</h1>");
|
||||
sb.AppendLine($"<p><strong>Tenant:</strong> {System.Net.WebUtility.HtmlEncode(digest.TenantId)}<br>");
|
||||
sb.AppendLine($"<strong>Period:</strong> {digest.From:yyyy-MM-dd HH:mm} to {digest.To:yyyy-MM-dd HH:mm} UTC</p>");
|
||||
|
||||
sb.AppendLine("<h2>Summary</h2>");
|
||||
sb.AppendLine("<table>");
|
||||
sb.AppendLine("<tr><th>Metric</th><th>Count</th></tr>");
|
||||
sb.AppendLine($"<tr><td>Total Events</td><td>{digest.Summary.TotalEvents}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>New Incidents</td><td>{digest.Summary.NewIncidents}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>Resolved</td><td>{digest.Summary.ResolvedIncidents}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>Acknowledged</td><td>{digest.Summary.AcknowledgedIncidents}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>Open</td><td>{digest.Summary.OpenIncidents}</td></tr>");
|
||||
sb.AppendLine("</table>");
|
||||
|
||||
if (digest.Incidents.Count > 0)
|
||||
{
|
||||
sb.AppendLine("<h2>Incidents</h2>");
|
||||
foreach (var incident in digest.Incidents)
|
||||
{
|
||||
var statusClass = $"status-{incident.Status.ToString().ToLowerInvariant()}";
|
||||
sb.AppendLine($"<div class='incident'>");
|
||||
sb.AppendLine($"<h3><span class='{statusClass}'>[{incident.Status}]</span> {System.Net.WebUtility.HtmlEncode(incident.Title)}</h3>");
|
||||
sb.AppendLine($"<p><strong>ID:</strong> {incident.IncidentId}<br>");
|
||||
sb.AppendLine($"<strong>Kind:</strong> {System.Net.WebUtility.HtmlEncode(incident.EventKind)}<br>");
|
||||
sb.AppendLine($"<strong>Events:</strong> {incident.EventCount}<br>");
|
||||
sb.AppendLine($"<strong>First:</strong> {incident.FirstOccurrence:yyyy-MM-dd HH:mm} UTC<br>");
|
||||
sb.AppendLine($"<strong>Last:</strong> {incident.LastOccurrence:yyyy-MM-dd HH:mm} UTC</p>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
}
|
||||
|
||||
if (digest.HasMore)
|
||||
{
|
||||
sb.AppendLine($"<p><em>... and {digest.TotalIncidentCount - digest.Incidents.Count} more incidents</em></p>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderJson(DigestResult digest)
|
||||
{
|
||||
return JsonSerializer.Serialize(digest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
|
||||
private static string RenderSlackBlocks(DigestResult digest)
|
||||
{
|
||||
var blocks = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "header",
|
||||
text = new { type = "plain_text", text = "📊 Notification Digest", emoji = true }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new object[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*Tenant:*\n{digest.TenantId}" },
|
||||
new { type = "mrkdwn", text = $"*Period:*\n{digest.From:MMM dd HH:mm} - {digest.To:MMM dd HH:mm} UTC" }
|
||||
}
|
||||
},
|
||||
new { type = "divider" },
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = "*Summary*" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new object[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"🔴 *Open:* {digest.Summary.OpenIncidents}" },
|
||||
new { type = "mrkdwn", text = $"🟡 *Acknowledged:* {digest.Summary.AcknowledgedIncidents}" },
|
||||
new { type = "mrkdwn", text = $"🟢 *Resolved:* {digest.Summary.ResolvedIncidents}" },
|
||||
new { type = "mrkdwn", text = $"📈 *Total Events:* {digest.Summary.TotalEvents}" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (digest.Incidents.Count > 0)
|
||||
{
|
||||
blocks.Add(new { type = "divider" });
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = $"*Top Incidents ({digest.Incidents.Count})*" }
|
||||
});
|
||||
|
||||
foreach (var incident in digest.Incidents.Take(5))
|
||||
{
|
||||
var statusEmoji = incident.Status switch
|
||||
{
|
||||
IncidentStatus.Open => "🔴",
|
||||
IncidentStatus.Acknowledged => "🟡",
|
||||
IncidentStatus.Resolved => "🟢",
|
||||
_ => "⚪"
|
||||
};
|
||||
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"{statusEmoji} *{incident.Title}*\n`{incident.IncidentId}` | {incident.EventKind} | {incident.EventCount} events"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (digest.HasMore)
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"_... and {digest.TotalIncidentCount - digest.Incidents.Count} more incidents_" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(new { blocks }, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for digest generation.
|
||||
/// </summary>
|
||||
public sealed class DigestOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:Digest";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum incidents to include in a single digest.
|
||||
/// </summary>
|
||||
public int MaxIncidentsPerDigest { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Number of top affected items to include in summary.
|
||||
/// </summary>
|
||||
public int TopAffectedCount { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to render content in multiple formats.
|
||||
/// </summary>
|
||||
public bool RenderContent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to render Slack blocks format.
|
||||
/// </summary>
|
||||
public bool RenderSlackBlocks { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip digest if no activity in period.
|
||||
/// </summary>
|
||||
public bool SkipEmptyDigests { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that runs digest generation on configured schedules.
|
||||
/// </summary>
|
||||
public sealed class DigestScheduleRunner : BackgroundService
|
||||
{
|
||||
private readonly IDigestGenerator _digestGenerator;
|
||||
private readonly IDigestDistributor _distributor;
|
||||
private readonly IDigestTenantProvider _tenantProvider;
|
||||
private readonly DigestScheduleOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DigestScheduleRunner> _logger;
|
||||
|
||||
public DigestScheduleRunner(
|
||||
IDigestGenerator digestGenerator,
|
||||
IDigestDistributor distributor,
|
||||
IDigestTenantProvider tenantProvider,
|
||||
IOptions<DigestScheduleOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DigestScheduleRunner> logger)
|
||||
{
|
||||
_digestGenerator = digestGenerator ?? throw new ArgumentNullException(nameof(digestGenerator));
|
||||
_distributor = distributor ?? throw new ArgumentNullException(nameof(distributor));
|
||||
_tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Digest schedule runner is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Digest schedule runner started with {ScheduleCount} schedules.",
|
||||
_options.Schedules.Count);
|
||||
|
||||
// Run each schedule in parallel
|
||||
var scheduleTasks = _options.Schedules
|
||||
.Where(s => s.Enabled)
|
||||
.Select(schedule => RunScheduleAsync(schedule, stoppingToken))
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(scheduleTasks);
|
||||
}
|
||||
|
||||
private async Task RunScheduleAsync(DigestSchedule schedule, CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Starting digest schedule '{Name}' with interval {Interval}.",
|
||||
schedule.Name, schedule.Interval);
|
||||
|
||||
// Calculate initial delay to align with schedule
|
||||
var initialDelay = CalculateInitialDelay(schedule);
|
||||
if (initialDelay > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Schedule '{Name}' waiting {Delay} for initial alignment.",
|
||||
schedule.Name, initialDelay);
|
||||
await Task.Delay(initialDelay, stoppingToken);
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteScheduleAsync(schedule, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error executing digest schedule '{Name}'. Will retry after interval.",
|
||||
schedule.Name);
|
||||
}
|
||||
|
||||
await Task.Delay(schedule.Interval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Digest schedule '{Name}' stopped.", schedule.Name);
|
||||
}
|
||||
|
||||
private async Task ExecuteScheduleAsync(DigestSchedule schedule, CancellationToken stoppingToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var query = new DigestQuery
|
||||
{
|
||||
From = now - schedule.LookbackPeriod,
|
||||
To = now,
|
||||
IncludeResolved = schedule.IncludeResolved,
|
||||
MaxIncidents = schedule.MaxIncidents,
|
||||
ScheduleName = schedule.Name
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Executing schedule '{Name}' for period {From} to {To}.",
|
||||
schedule.Name, query.From, query.To);
|
||||
|
||||
// Get tenants to generate digests for
|
||||
var tenants = await _tenantProvider.GetTenantsForScheduleAsync(schedule.Name, stoppingToken);
|
||||
|
||||
var successCount = 0;
|
||||
var errorCount = 0;
|
||||
|
||||
foreach (var tenantId in tenants)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _digestGenerator.GenerateAsync(tenantId, query, stoppingToken);
|
||||
|
||||
if (!result.Summary.HasActivity && _options.SkipEmptyDigests)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping empty digest for tenant {TenantId} on schedule '{Schedule}'.",
|
||||
tenantId, schedule.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _distributor.DistributeAsync(result, schedule, stoppingToken);
|
||||
successCount++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Distributed digest {DigestId} for tenant {TenantId} via schedule '{Schedule}'.",
|
||||
result.DigestId, tenantId, schedule.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCount++;
|
||||
_logger.LogError(ex,
|
||||
"Failed to generate/distribute digest for tenant {TenantId} on schedule '{Schedule}'.",
|
||||
tenantId, schedule.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed schedule '{Name}': {Success} successful, {Errors} errors out of {Total} tenants.",
|
||||
schedule.Name, successCount, errorCount, tenants.Count);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateInitialDelay(DigestSchedule schedule)
|
||||
{
|
||||
if (!schedule.AlignToInterval)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var intervalTicks = schedule.Interval.Ticks;
|
||||
var currentTicks = now.Ticks;
|
||||
|
||||
// Calculate next aligned time
|
||||
var nextAlignedTicks = ((currentTicks / intervalTicks) + 1) * intervalTicks;
|
||||
var nextAligned = new DateTimeOffset(nextAlignedTicks, TimeSpan.Zero);
|
||||
|
||||
return nextAligned - now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributes generated digests through configured channels.
|
||||
/// </summary>
|
||||
public interface IDigestDistributor
|
||||
{
|
||||
/// <summary>
|
||||
/// Distributes a digest via the appropriate channels.
|
||||
/// </summary>
|
||||
Task DistributeAsync(
|
||||
DigestResult digest,
|
||||
DigestSchedule schedule,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides tenants that should receive digests for a given schedule.
|
||||
/// </summary>
|
||||
public interface IDigestTenantProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets tenant IDs that should receive digests for the specified schedule.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetTenantsForScheduleAsync(
|
||||
string scheduleName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDigestDistributor"/> using channel adapters.
|
||||
/// </summary>
|
||||
public sealed class ChannelDigestDistributor : IDigestDistributor
|
||||
{
|
||||
private readonly IChannelAdapterFactory _channelFactory;
|
||||
private readonly ILogger<ChannelDigestDistributor> _logger;
|
||||
|
||||
public ChannelDigestDistributor(
|
||||
IChannelAdapterFactory channelFactory,
|
||||
ILogger<ChannelDigestDistributor> logger)
|
||||
{
|
||||
_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task DistributeAsync(
|
||||
DigestResult digest,
|
||||
DigestSchedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var channelConfig in schedule.Channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
var adapter = _channelFactory.Create(channelConfig.Type);
|
||||
var content = SelectContent(digest, channelConfig.Type);
|
||||
|
||||
await adapter.SendAsync(new ChannelMessage
|
||||
{
|
||||
ChannelType = channelConfig.Type,
|
||||
Destination = channelConfig.Destination,
|
||||
Subject = $"Notification Digest - {digest.TenantId}",
|
||||
Body = content,
|
||||
Format = channelConfig.Format ?? GetDefaultFormat(channelConfig.Type),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["digestId"] = digest.DigestId,
|
||||
["tenantId"] = digest.TenantId,
|
||||
["scheduleName"] = schedule.Name,
|
||||
["from"] = digest.From.ToString("O"),
|
||||
["to"] = digest.To.ToString("O")
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Sent digest {DigestId} to channel {Channel} ({Destination}).",
|
||||
digest.DigestId, channelConfig.Type, channelConfig.Destination);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to send digest {DigestId} to channel {Channel}.",
|
||||
digest.DigestId, channelConfig.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SelectContent(DigestResult digest, string channelType)
|
||||
{
|
||||
if (digest.Content is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return channelType.ToLowerInvariant() switch
|
||||
{
|
||||
"slack" => digest.Content.SlackBlocks ?? digest.Content.Markdown ?? digest.Content.PlainText ?? "",
|
||||
"email" => digest.Content.Html ?? digest.Content.PlainText ?? "",
|
||||
"webhook" => digest.Content.Json ?? "",
|
||||
_ => digest.Content.PlainText ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultFormat(string channelType)
|
||||
{
|
||||
return channelType.ToLowerInvariant() switch
|
||||
{
|
||||
"slack" => "blocks",
|
||||
"email" => "html",
|
||||
"webhook" => "json",
|
||||
_ => "text"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDigestTenantProvider : IDigestTenantProvider
|
||||
{
|
||||
private readonly List<string> _tenants = [];
|
||||
|
||||
public void AddTenant(string tenantId) => _tenants.Add(tenantId);
|
||||
|
||||
public void RemoveTenant(string tenantId) => _tenants.Remove(tenantId);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetTenantsForScheduleAsync(
|
||||
string scheduleName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(_tenants.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for digest scheduling.
|
||||
/// </summary>
|
||||
public sealed class DigestScheduleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:DigestSchedule";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the digest scheduler is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip digests with no activity.
|
||||
/// </summary>
|
||||
public bool SkipEmptyDigests { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Configured digest schedules.
|
||||
/// </summary>
|
||||
public List<DigestSchedule> Schedules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single digest schedule configuration.
|
||||
/// </summary>
|
||||
public sealed class DigestSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name for this schedule.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this schedule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How often to run this schedule.
|
||||
/// </summary>
|
||||
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// How far back to look for incidents.
|
||||
/// </summary>
|
||||
public TimeSpan LookbackPeriod { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to align execution to interval boundaries.
|
||||
/// </summary>
|
||||
public bool AlignToInterval { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum incidents to include.
|
||||
/// </summary>
|
||||
public int MaxIncidents { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include resolved incidents.
|
||||
/// </summary>
|
||||
public bool IncludeResolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Channels to distribute digests through.
|
||||
/// </summary>
|
||||
public List<DigestChannelConfig> Channels { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel configuration for digest distribution.
|
||||
/// </summary>
|
||||
public sealed class DigestChannelConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type (email, slack, webhook, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Destination (email address, webhook URL, channel ID, etc.).
|
||||
/// </summary>
|
||||
public required string Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content format to use (html, markdown, json, blocks).
|
||||
/// </summary>
|
||||
public string? Format { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message to send through a channel.
|
||||
/// </summary>
|
||||
public sealed class ChannelMessage
|
||||
{
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Destination { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string Format { get; init; } = "text";
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Cronos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Manages scheduled digest configurations.
|
||||
/// </summary>
|
||||
public interface IDigestScheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all scheduled digests for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DigestSchedule>> GetSchedulesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific schedule by ID.
|
||||
/// </summary>
|
||||
Task<DigestSchedule?> GetScheduleAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a digest schedule.
|
||||
/// </summary>
|
||||
Task<DigestSchedule> UpsertScheduleAsync(
|
||||
DigestSchedule schedule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a digest schedule.
|
||||
/// </summary>
|
||||
Task<bool> DeleteScheduleAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets schedules due for execution.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DigestSchedule>> GetDueSchedulesAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the last run time for a schedule.
|
||||
/// </summary>
|
||||
Task UpdateLastRunAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
DateTimeOffset runTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Digest schedule configuration.
|
||||
/// </summary>
|
||||
public sealed record DigestSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique schedule ID.
|
||||
/// </summary>
|
||||
public required string ScheduleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the schedule is enabled.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression for schedule timing (5-field or 6-field).
|
||||
/// </summary>
|
||||
public required string CronExpression { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timezone for cron evaluation (IANA format).
|
||||
/// </summary>
|
||||
public string? Timezone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest type to generate.
|
||||
/// </summary>
|
||||
public DigestType DigestType { get; init; } = DigestType.Daily;
|
||||
|
||||
/// <summary>
|
||||
/// Period lookback for the digest (e.g., 24 hours for daily).
|
||||
/// </summary>
|
||||
public TimeSpan? LookbackPeriod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format.
|
||||
/// </summary>
|
||||
public DigestFormat Format { get; init; } = DigestFormat.Html;
|
||||
|
||||
/// <summary>
|
||||
/// Event kind filters (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? EventKindFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include resolved incidents.
|
||||
/// </summary>
|
||||
public bool IncludeResolved { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Recipients for the digest.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DigestRecipient>? Recipients { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last successful run time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Next scheduled run time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextRunAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the schedule was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created the schedule.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Digest recipient configuration.
|
||||
/// </summary>
|
||||
public sealed record DigestRecipient
|
||||
{
|
||||
/// <summary>
|
||||
/// Recipient type (email, webhook, channel).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient address (email, URL, channel ID).
|
||||
/// </summary>
|
||||
public required string Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional display name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDigestScheduler"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDigestScheduler : IDigestScheduler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DigestSchedule> _schedules = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryDigestScheduler> _logger;
|
||||
|
||||
public InMemoryDigestScheduler(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryDigestScheduler> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DigestSchedule>> GetSchedulesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var schedules = _schedules.Values
|
||||
.Where(s => s.TenantId == tenantId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DigestSchedule>>(schedules);
|
||||
}
|
||||
|
||||
public Task<DigestSchedule?> GetScheduleAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, scheduleId);
|
||||
_schedules.TryGetValue(key, out var schedule);
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<DigestSchedule> UpsertScheduleAsync(
|
||||
DigestSchedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
|
||||
// Calculate next run time
|
||||
var updatedSchedule = schedule with
|
||||
{
|
||||
NextRunAt = CalculateNextRun(schedule)
|
||||
};
|
||||
|
||||
var key = BuildKey(schedule.TenantId, schedule.ScheduleId);
|
||||
_schedules[key] = updatedSchedule;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Upserted digest schedule {ScheduleId} for tenant {TenantId}, next run at {NextRun}.",
|
||||
schedule.ScheduleId, schedule.TenantId, updatedSchedule.NextRunAt);
|
||||
|
||||
return Task.FromResult(updatedSchedule);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteScheduleAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, scheduleId);
|
||||
var removed = _schedules.TryRemove(key, out _);
|
||||
|
||||
if (removed)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleted digest schedule {ScheduleId} for tenant {TenantId}.",
|
||||
scheduleId, tenantId);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DigestSchedule>> GetDueSchedulesAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dueSchedules = _schedules.Values
|
||||
.Where(s => s.Enabled)
|
||||
.Where(s => s.NextRunAt.HasValue && s.NextRunAt.Value <= asOf)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DigestSchedule>>(dueSchedules);
|
||||
}
|
||||
|
||||
public Task UpdateLastRunAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
DateTimeOffset runTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildKey(tenantId, scheduleId);
|
||||
|
||||
if (_schedules.TryGetValue(key, out var schedule))
|
||||
{
|
||||
var updatedSchedule = schedule with
|
||||
{
|
||||
LastRunAt = runTime,
|
||||
NextRunAt = CalculateNextRun(schedule, runTime)
|
||||
};
|
||||
|
||||
_schedules[key] = updatedSchedule;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated last run for schedule {ScheduleId}, next run at {NextRun}.",
|
||||
scheduleId, updatedSchedule.NextRunAt);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private DateTimeOffset? CalculateNextRun(DigestSchedule schedule, DateTimeOffset? from = null)
|
||||
{
|
||||
if (!schedule.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var expression = CronExpression.Parse(schedule.CronExpression, CronFormat.IncludeSeconds);
|
||||
var fromTime = from ?? _timeProvider.GetUtcNow();
|
||||
|
||||
TimeZoneInfo tz = TimeZoneInfo.Utc;
|
||||
if (!string.IsNullOrWhiteSpace(schedule.Timezone))
|
||||
{
|
||||
try
|
||||
{
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid timezone, use UTC
|
||||
}
|
||||
}
|
||||
|
||||
var next = expression.GetNextOccurrence(fromTime.UtcDateTime, tz);
|
||||
return next.HasValue ? new DateTimeOffset(next.Value, TimeSpan.Zero) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to parse cron expression '{Cron}' for schedule {ScheduleId}.",
|
||||
schedule.CronExpression, schedule.ScheduleId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildKey(string tenantId, string scheduleId) =>
|
||||
$"{tenantId}:{scheduleId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for digest scheduling.
|
||||
/// </summary>
|
||||
public sealed class DigestSchedulerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Notifier:DigestScheduler";
|
||||
|
||||
/// <summary>
|
||||
/// How often to check for due schedules.
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrent digest generations.
|
||||
/// </summary>
|
||||
public int MaxConcurrent { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Default lookback periods by digest type.
|
||||
/// </summary>
|
||||
public Dictionary<DigestType, TimeSpan> DefaultLookbacks { get; set; } = new()
|
||||
{
|
||||
[DigestType.Daily] = TimeSpan.FromHours(24),
|
||||
[DigestType.Weekly] = TimeSpan.FromDays(7),
|
||||
[DigestType.Monthly] = TimeSpan.FromDays(30)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering digest services.
|
||||
/// </summary>
|
||||
public static class DigestServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds digest generator and scheduler services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDigestServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register options
|
||||
services.Configure<DigestOptions>(
|
||||
configuration.GetSection(DigestOptions.SectionName));
|
||||
services.Configure<DigestScheduleOptions>(
|
||||
configuration.GetSection(DigestScheduleOptions.SectionName));
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<IDigestGenerator, DigestGenerator>();
|
||||
services.AddSingleton<IDigestDistributor, ChannelDigestDistributor>();
|
||||
services.AddSingleton<IDigestTenantProvider, InMemoryDigestTenantProvider>();
|
||||
|
||||
// Register background service
|
||||
services.AddHostedService<DigestScheduleRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds digest services with custom implementations.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDigestServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<DigestServiceBuilder> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Register options
|
||||
services.Configure<DigestOptions>(
|
||||
configuration.GetSection(DigestOptions.SectionName));
|
||||
services.Configure<DigestScheduleOptions>(
|
||||
configuration.GetSection(DigestScheduleOptions.SectionName));
|
||||
|
||||
// Apply custom configuration
|
||||
var builder = new DigestServiceBuilder(services);
|
||||
configure(builder);
|
||||
|
||||
// Register defaults for any services not configured
|
||||
services.TryAddSingleton<IDigestGenerator, DigestGenerator>();
|
||||
services.TryAddSingleton<IDigestDistributor, ChannelDigestDistributor>();
|
||||
services.TryAddSingleton<IDigestTenantProvider, InMemoryDigestTenantProvider>();
|
||||
|
||||
// Register background service if not disabled
|
||||
if (!builder.DisableScheduler)
|
||||
{
|
||||
services.AddHostedService<DigestScheduleRunner>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void TryAddSingleton<TService, TImplementation>(this IServiceCollection services)
|
||||
where TService : class
|
||||
where TImplementation : class, TService
|
||||
{
|
||||
if (!services.Any(d => d.ServiceType == typeof(TService)))
|
||||
{
|
||||
services.AddSingleton<TService, TImplementation>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for customizing digest service registrations.
|
||||
/// </summary>
|
||||
public sealed class DigestServiceBuilder
|
||||
{
|
||||
private readonly IServiceCollection _services;
|
||||
|
||||
internal DigestServiceBuilder(IServiceCollection services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
internal bool DisableScheduler { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom digest generator implementation.
|
||||
/// </summary>
|
||||
public DigestServiceBuilder UseGenerator<TGenerator>()
|
||||
where TGenerator : class, IDigestGenerator
|
||||
{
|
||||
_services.AddSingleton<IDigestGenerator, TGenerator>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom digest distributor implementation.
|
||||
/// </summary>
|
||||
public DigestServiceBuilder UseDistributor<TDistributor>()
|
||||
where TDistributor : class, IDigestDistributor
|
||||
{
|
||||
_services.AddSingleton<IDigestDistributor, TDistributor>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom tenant provider implementation.
|
||||
/// </summary>
|
||||
public DigestServiceBuilder UseTenantProvider<TProvider>()
|
||||
where TProvider : class, IDigestTenantProvider
|
||||
{
|
||||
_services.AddSingleton<IDigestTenantProvider, TProvider>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables the background scheduler (useful for testing or manual triggering).
|
||||
/// </summary>
|
||||
public DigestServiceBuilder WithoutScheduler()
|
||||
{
|
||||
DisableScheduler = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures digest options.
|
||||
/// </summary>
|
||||
public DigestServiceBuilder ConfigureOptions(Action<DigestOptions> configure)
|
||||
{
|
||||
_services.PostConfigure(configure);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures schedule options.
|
||||
/// </summary>
|
||||
public DigestServiceBuilder ConfigureSchedules(Action<DigestScheduleOptions> configure)
|
||||
{
|
||||
_services.PostConfigure(configure);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user