Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 21:45:32 +02:00
510 changed files with 138401 additions and 51276 deletions

View File

@@ -46,6 +46,7 @@ internal static class CommandFactory
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken));
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
@@ -1850,4 +1851,64 @@ internal static class CommandFactory
return attest;
}
private static Command BuildRiskProfileCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
_ = cancellationToken;
var riskProfile = new Command("risk-profile", "Manage risk profile schemas and validation.");
var validate = new Command("validate", "Validate a risk profile JSON file against the schema.");
var inputOption = new Option<string>("--input", new[] { "-i" })
{
Description = "Path to the risk profile JSON file to validate.",
Required = true
};
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table (default) or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write validation report to the specified file path."
};
var strictOption = new Option<bool>("--strict")
{
Description = "Treat warnings as errors (exit code 1 on any issue)."
};
validate.Add(inputOption);
validate.Add(formatOption);
validate.Add(outputOption);
validate.Add(strictOption);
validate.SetAction((parseResult, _) =>
{
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "table";
var output = parseResult.GetValue(outputOption);
var strict = parseResult.GetValue(strictOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleRiskProfileValidateAsync(input, format, output, strict, verbose);
});
var schema = new Command("schema", "Display or export the risk profile JSON schema.");
var schemaOutputOption = new Option<string?>("--output")
{
Description = "Write the schema to the specified file path."
};
schema.Add(schemaOutputOption);
schema.SetAction((parseResult, _) =>
{
var output = parseResult.GetValue(schemaOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleRiskProfileSchemaAsync(output, verbose);
});
riskProfile.Add(validate);
riskProfile.Add(schema);
return riskProfile;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +1,66 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<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.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<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.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,28 @@
# StellaOps Notifier Service — Agent Charter
## Mission
Build Notifications Studio (Epic11) so StellaOps delivers policy-aware, explainable, tenant-scoped notifications without flooding humans. Honor the imposed rule: any work of this type must propagate everywhere it belongs.
## Responsibilities
- Maintain event ingestion, rule evaluation, correlation, throttling, templating, dispatch, digests, and escalation pipelines.
- Coordinate with Orchestrator, Policy Engine, Findings Ledger, VEX Lens, Export Center, Authority, Console, CLI, and DevOps teams to ensure consistent event envelopes, provenance links, and RBAC.
- Guarantee deterministic, auditable notification outcomes with provenance, signing/ack security, and localization.
## Module Layout
- `StellaOps.Notifier.Core/` — rule engine, routing, correlation, and template orchestration primitives.
- `StellaOps.Notifier.Infrastructure/` — persistence, integration adapters, and channel implementations.
- `StellaOps.Notifier.WebService/` — HTTP APIs (rules, incidents, templates, feeds).
- `StellaOps.Notifier.Worker/` — background dispatchers, digest builders, simulation hosts.
- `StellaOps.Notifier.Tests/` — foundational unit tests covering core/infrastructure behavior.
- `StellaOps.Notifier.sln` — solution bundling the Notifier projects.
## Required Reading
- `docs/modules/notify/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
# StellaOps Notifier Service — Agent Charter
## Mission
Build Notifications Studio (Epic11) so StellaOps delivers policy-aware, explainable, tenant-scoped notifications without flooding humans. Honor the imposed rule: any work of this type must propagate everywhere it belongs.
## Responsibilities
- Maintain event ingestion, rule evaluation, correlation, throttling, templating, dispatch, digests, and escalation pipelines.
- Coordinate with Orchestrator, Policy Engine, Findings Ledger, VEX Lens, Export Center, Authority, Console, CLI, and DevOps teams to ensure consistent event envelopes, provenance links, and RBAC.
- Guarantee deterministic, auditable notification outcomes with provenance, signing/ack security, and localization.
## Module Layout
- `StellaOps.Notifier.Core/` — rule engine, routing, correlation, and template orchestration primitives.
- `StellaOps.Notifier.Infrastructure/` — persistence, integration adapters, and channel implementations.
- `StellaOps.Notifier.WebService/` — HTTP APIs (rules, incidents, templates, feeds).
- `StellaOps.Notifier.Worker/` — background dispatchers, digest builders, simulation hosts.
- `StellaOps.Notifier.Tests/` — foundational unit tests covering core/infrastructure behavior.
- `StellaOps.Notifier.sln` — solution bundling the Notifier projects.
## Required Reading
- `docs/modules/notify/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -1,77 +1,77 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class AttestationTemplateCoverageTests
{
private static readonly string RepoRoot = LocateRepoRoot();
[Fact]
public void Attestation_templates_cover_required_channels()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var required = new Dictionary<string, string[]>
{
["tmpl-attest-verify-fail"] = new[] { "slack", "email", "webhook" },
["tmpl-attest-expiry-warning"] = new[] { "email", "slack" },
["tmpl-attest-key-rotation"] = new[] { "email", "webhook" },
["tmpl-attest-transparency-anomaly"] = new[] { "slack", "webhook" }
};
foreach (var pair in required)
{
var matches = templates.Where(t => t.Document.GetProperty("key").GetString() == pair.Key);
var channels = matches
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = pair.Value.Where(requiredChannel => !channels.Contains(requiredChannel)).ToArray();
Assert.True(missing.Length == 0, $"{pair.Key} missing channels: {string.Join(", ", missing)}");
}
}
[Fact]
public void Attestation_templates_include_schema_and_locale_metadata()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("schemaVersion", out var schemaVersion) && !string.IsNullOrWhiteSpace(schemaVersion.GetString()), $"schemaVersion missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("locale", out var locale) && !string.IsNullOrWhiteSpace(locale.GetString()), $"locale missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("key", out var key) && !string.IsNullOrWhiteSpace(key.GetString()), $"key missing for {Path.GetFileName(path)}");
}
}
private static string LocateRepoRoot()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "attestation");
if (Directory.Exists(candidate))
{
return directory;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate repository root containing offline/notifier/templates/attestation.");
}
}
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class AttestationTemplateCoverageTests
{
private static readonly string RepoRoot = LocateRepoRoot();
[Fact]
public void Attestation_templates_cover_required_channels()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var required = new Dictionary<string, string[]>
{
["tmpl-attest-verify-fail"] = new[] { "slack", "email", "webhook" },
["tmpl-attest-expiry-warning"] = new[] { "email", "slack" },
["tmpl-attest-key-rotation"] = new[] { "email", "webhook" },
["tmpl-attest-transparency-anomaly"] = new[] { "slack", "webhook" }
};
foreach (var pair in required)
{
var matches = templates.Where(t => t.Document.GetProperty("key").GetString() == pair.Key);
var channels = matches
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = pair.Value.Where(requiredChannel => !channels.Contains(requiredChannel)).ToArray();
Assert.True(missing.Length == 0, $"{pair.Key} missing channels: {string.Join(", ", missing)}");
}
}
[Fact]
public void Attestation_templates_include_schema_and_locale_metadata()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("schemaVersion", out var schemaVersion) && !string.IsNullOrWhiteSpace(schemaVersion.GetString()), $"schemaVersion missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("locale", out var locale) && !string.IsNullOrWhiteSpace(locale.GetString()), $"locale missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("key", out var key) && !string.IsNullOrWhiteSpace(key.GetString()), $"key missing for {Path.GetFileName(path)}");
}
}
private static string LocateRepoRoot()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "attestation");
if (Directory.Exists(candidate))
{
return directory;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate repository root containing offline/notifier/templates/attestation.");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,66 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class DeprecationTemplateTests
{
[Fact]
public void Deprecation_templates_cover_slack_and_email()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var channels = templates
.Where(t => t.Document.GetProperty("key").GetString() == "tmpl-api-deprecation")
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("slack", channels);
Assert.Contains("email", channels);
}
[Fact]
public void Deprecation_templates_require_core_metadata()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("metadata", out var meta), $"metadata missing for {Path.GetFileName(path)}");
// Ensure documented metadata keys are present for offline baseline.
Assert.True(meta.TryGetProperty("version", out _), $"metadata.version missing for {Path.GetFileName(path)}");
Assert.True(meta.TryGetProperty("author", out _), $"metadata.author missing for {Path.GetFileName(path)}");
}
}
private static string LocateOfflineDeprecationDir()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "deprecation");
if (Directory.Exists(candidate))
{
return candidate;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate offline/notifier/templates/deprecation directory.");
}
}
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class DeprecationTemplateTests
{
[Fact]
public void Deprecation_templates_cover_slack_and_email()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var channels = templates
.Where(t => t.Document.GetProperty("key").GetString() == "tmpl-api-deprecation")
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("slack", channels);
Assert.Contains("email", channels);
}
[Fact]
public void Deprecation_templates_require_core_metadata()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("metadata", out var meta), $"metadata missing for {Path.GetFileName(path)}");
// Ensure documented metadata keys are present for offline baseline.
Assert.True(meta.TryGetProperty("version", out _), $"metadata.version missing for {Path.GetFileName(path)}");
Assert.True(meta.TryGetProperty("author", out _), $"metadata.author missing for {Path.GetFileName(path)}");
}
}
private static string LocateOfflineDeprecationDir()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "deprecation");
if (Directory.Exists(candidate))
{
return candidate;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate offline/notifier/templates/deprecation directory.");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,89 +1,89 @@
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
using Xunit;
namespace StellaOps.Notifier.Tests;
public class HttpEgressSloSinkTests
{
[Fact]
public async Task PublishAsync_NoWebhook_DoesNothing()
{
var handler = new StubHandler();
var sink = CreateSink(handler, new EgressSloOptions { Webhook = null });
await sink.PublishAsync(BuildContext(), CancellationToken.None);
Assert.Equal(0, handler.SendCount);
}
[Fact]
public async Task PublishAsync_SendsWebhookWithPayload()
{
var handler = new StubHandler();
var sink = CreateSink(handler, new EgressSloOptions { Webhook = "https://example.test/slo", TimeoutSeconds = 5 });
await sink.PublishAsync(BuildContext(), CancellationToken.None);
Assert.Equal(1, handler.SendCount);
var request = handler.LastRequest!;
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://example.test/slo", request.RequestUri!.ToString());
}
private static HttpEgressSloSink CreateSink(HttpMessageHandler handler, EgressSloOptions options)
{
var factory = new StubHttpClientFactory(handler);
return new HttpEgressSloSink(factory, Options.Create(options), NullLogger<HttpEgressSloSink>.Instance);
}
private static EgressSloContext BuildContext()
{
var evt = Notify.Models.NotifyEvent.Create(
Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: new System.Text.Json.Nodes.JsonObject(),
actor: "tester",
version: "1");
var ctx = EgressSloContext.FromNotifyEvent(evt);
ctx.AddDelivery("Slack", "tmpl", evt.Kind);
return ctx;
}
private sealed class StubHandler : HttpMessageHandler
{
public int SendCount { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SendCount++;
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubHttpClientFactory(HttpMessageHandler handler)
{
_handler = handler;
}
public HttpClient CreateClient(string name)
{
return new HttpClient(_handler, disposeHandler: false);
}
}
}
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
using Xunit;
namespace StellaOps.Notifier.Tests;
public class HttpEgressSloSinkTests
{
[Fact]
public async Task PublishAsync_NoWebhook_DoesNothing()
{
var handler = new StubHandler();
var sink = CreateSink(handler, new EgressSloOptions { Webhook = null });
await sink.PublishAsync(BuildContext(), CancellationToken.None);
Assert.Equal(0, handler.SendCount);
}
[Fact]
public async Task PublishAsync_SendsWebhookWithPayload()
{
var handler = new StubHandler();
var sink = CreateSink(handler, new EgressSloOptions { Webhook = "https://example.test/slo", TimeoutSeconds = 5 });
await sink.PublishAsync(BuildContext(), CancellationToken.None);
Assert.Equal(1, handler.SendCount);
var request = handler.LastRequest!;
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://example.test/slo", request.RequestUri!.ToString());
}
private static HttpEgressSloSink CreateSink(HttpMessageHandler handler, EgressSloOptions options)
{
var factory = new StubHttpClientFactory(handler);
return new HttpEgressSloSink(factory, Options.Create(options), NullLogger<HttpEgressSloSink>.Instance);
}
private static EgressSloContext BuildContext()
{
var evt = Notify.Models.NotifyEvent.Create(
Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: new System.Text.Json.Nodes.JsonObject(),
actor: "tester",
version: "1");
var ctx = EgressSloContext.FromNotifyEvent(evt);
ctx.AddDelivery("Slack", "tmpl", evt.Kind);
return ctx;
}
private sealed class StubHandler : HttpMessageHandler
{
public int SendCount { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SendCount++;
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubHttpClientFactory(HttpMessageHandler handler)
{
_handler = handler;
}
public HttpClient CreateClient(string name)
{
return new HttpClient(_handler, disposeHandler: false);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ using StellaOps.Notifier.Tests.Support;
using StellaOps.Notify.Storage.Mongo.Repositories;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Notifier.Tests;
namespace StellaOps.Notifier.Tests;
public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFactory>
{
private readonly HttpClient _client;
@@ -18,7 +18,7 @@ public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFact
_client = factory.CreateClient();
_packRepo = factory.PackRepo;
}
#if false // disabled until test host wiring stabilises
[Fact]
public async Task OpenApi_endpoint_serves_yaml_with_scope_header()
@@ -27,29 +27,29 @@ public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFact
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
#endif
[Fact(Explicit = true, Skip = "Pending test host wiring")]
public async Task Deprecation_headers_emitted_for_api_surface()
{
var response = await _client.GetAsync("/api/v1/notify/rules", TestContext.Current.CancellationToken);
Assert.True(response.Headers.TryGetValues("Deprecation", out var depValues) &&
depValues.Contains("true"));
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues) &&
sunsetValues.Any());
Assert.True(response.Headers.TryGetValues("Link", out var linkValues) &&
linkValues.Any(v => v.Contains("rel=\"deprecation\"")));
}
Assert.True(response.Headers.TryGetValues("Deprecation", out var depValues) &&
depValues.Contains("true"));
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues) &&
sunsetValues.Any());
Assert.True(response.Headers.TryGetValues("Link", out var linkValues) &&
linkValues.Any(v => v.Contains("rel=\"deprecation\"")));
}
[Fact(Explicit = true, Skip = "Pending test host wiring")]
public async Task PackApprovals_endpoint_validates_missing_headers()
{
var content = new StringContent("""{"eventId":"00000000-0000-0000-0000-000000000001","issuedAt":"2025-11-17T16:00:00Z","kind":"pack.approval.granted","packId":"offline-kit","decision":"approved","actor":"task-runner"}""", Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/api/v1/notify/pack-approvals", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await _client.PostAsync("/api/v1/notify/pack-approvals", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact(Explicit = true, Skip = "Pending test host wiring")]
public async Task PackApprovals_endpoint_accepts_happy_path_and_echoes_resume_token()
{

View File

@@ -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("&lt;", result);
Assert.Contains("&gt;", 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,30 @@
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly List<NotifyAuditEntryDocument> _entries = new();
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
_entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
var items = _entries
.Where(e => e.TenantId == tenantId && (!since.HasValue || e.Timestamp >= since.Value))
.OrderByDescending(e => e.Timestamp)
.ToList();
if (limit is > 0)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly List<NotifyAuditEntryDocument> _entries = new();
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
_entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
var items = _entries
.Where(e => e.TenantId == tenantId && (!since.HasValue || e.Timestamp >= since.Value))
.OrderByDescending(e => e.Timestamp)
.ToList();
if (limit is > 0)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}

View File

@@ -1,18 +1,18 @@
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly Dictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_records[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public bool Exists(string tenantId, Guid eventId, string packId)
=> _records.ContainsKey((tenantId, eventId, packId));
}
{
private readonly Dictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_records[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public bool Exists(string tenantId, Guid eventId, string packId)
=> _records.ContainsKey((tenantId, eventId, packId));
}

View File

@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notifier.Worker.Processing;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notifier.Worker.Processing;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class TestEgressSloSink : IEgressSloSink
@@ -11,10 +11,10 @@ internal sealed class TestEgressSloSink : IEgressSloSink
private readonly ConcurrentBag<EgressSloContext> _contexts = new();
internal IReadOnlyCollection<EgressSloContext> Contexts => _contexts;
public Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken)
{
_contexts.Add(context);
return Task.CompletedTask;
}
}
public Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken)
{
_contexts.Add(context);
return Task.CompletedTask;
}
}

View File

@@ -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("&lt;script&gt;", 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,254 +1,254 @@
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents/{incidentId}/ack:
post:
summary: Acknowledge an incident notification
@@ -256,64 +256,64 @@ paths:
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IncidentId'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
default:
$ref: '#/components/responses/Error'
@@ -414,124 +414,124 @@ components:
required: true
description: Stable UUID to dedupe retries.
schema: { type: string, format: uuid }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
Incident:
type: object
required: [incidentId, kind, severity, startedAt]
@@ -579,35 +579,35 @@ components:
labels:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
public sealed record ChannelUpsertRequest
{
public string? Name { get; init; }
public NotifyChannelType? Type { get; init; }
public string? Endpoint { get; init; }
public string? Target { get; init; }
public string? SecretRef { get; init; }
public string? Description { get; init; }
}
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
public sealed record ChannelUpsertRequest
{
public string? Name { get; init; }
public NotifyChannelType? Type { get; init; }
public string? Endpoint { get; init; }
public string? Target { get; init; }
public string? SecretRef { get; init; }
public string? Description { get; init; }
}

View File

@@ -1,137 +1,137 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}

View File

@@ -1,149 +1,149 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
public sealed record OnCallLayerRequest
{
public string? LayerId { get; init; }
public string? Name { get; init; }
public int Priority { get; init; }
public DateTimeOffset RotationStartsAt { get; init; }
public TimeSpan RotationInterval { get; init; }
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
public sealed record OnCallLayerRequest
{
public string? LayerId { get; init; }
public string? Name { get; init; }
public int Priority { get; init; }
public DateTimeOffset RotationStartsAt { get; init; }
public TimeSpan RotationInterval { get; init; }
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}

View File

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

View File

@@ -1,45 +1,45 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}

View File

@@ -1,9 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed class PackApprovalAckRequest
{
[Required]
public string AckToken { get; init; } = string.Empty;
}
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; }
}

View File

@@ -1,45 +1,88 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed class PackApprovalRequest
{
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; init; }
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("policy")]
public PackApprovalPolicy? Policy { get; init; }
[JsonPropertyName("decision")]
public string Decision { get; init; } = string.Empty;
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
[JsonPropertyName("resumeToken")]
public string? ResumeToken { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("labels")]
public Dictionary<string, string>? Labels { get; init; }
}
public sealed class PackApprovalPolicy
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
}
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; }
}

View File

@@ -1,60 +1,60 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
public sealed class QuietHoursUpsertRequest
{
public required string Name { get; init; }
public required string CronExpression { get; init; }
public required TimeSpan Duration { get; init; }
public required string TimeZone { get; init; }
public string? ChannelId { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
public sealed class MaintenanceWindowUpsertRequest
{
public required string Name { get; init; }
public required DateTimeOffset StartsAt { get; init; }
public required DateTimeOffset EndsAt { get; init; }
public bool? SuppressNotifications { get; init; }
public string? Reason { get; init; }
public ImmutableArray<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
public sealed class ThrottleConfigUpsertRequest
{
public required string Name { get; init; }
public required TimeSpan DefaultWindow { get; init; }
public int? MaxNotificationsPerWindow { get; init; }
public string? ChannelId { get; init; }
public bool? IsDefault { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideCreateRequest
{
public required string OverrideType { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? ChannelId { get; init; }
public string? RuleId { get; init; }
public string? Reason { get; init; }
}
using System.Collections.Immutable;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
public sealed class QuietHoursUpsertRequest
{
public required string Name { get; init; }
public required string CronExpression { get; init; }
public required TimeSpan Duration { get; init; }
public required string TimeZone { get; init; }
public string? ChannelId { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
public sealed class MaintenanceWindowUpsertRequest
{
public required string Name { get; init; }
public required DateTimeOffset StartsAt { get; init; }
public required DateTimeOffset EndsAt { get; init; }
public bool? SuppressNotifications { get; init; }
public string? Reason { get; init; }
public ImmutableArray<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
public sealed class ThrottleConfigUpsertRequest
{
public required string Name { get; init; }
public required TimeSpan DefaultWindow { get; init; }
public int? MaxNotificationsPerWindow { get; init; }
public string? ChannelId { get; init; }
public bool? IsDefault { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideCreateRequest
{
public required string OverrideType { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? ChannelId { get; init; }
public string? RuleId { get; init; }
public string? Reason { get; init; }
}

View File

@@ -1,143 +1,143 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}

View File

@@ -1,33 +1,114 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a rule.
/// </summary>
public sealed record RuleUpsertRequest
{
public string? Name { get; init; }
public RuleMatchRequest? Match { get; init; }
public IReadOnlyList<RuleActionRequest>? Actions { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Match criteria for a rule.
/// </summary>
public sealed record RuleMatchRequest
{
public string[]? EventKinds { get; init; }
}
/// <summary>
/// Action definition for a rule.
/// </summary>
public sealed record RuleActionRequest
{
public string? ActionId { get; init; }
public string? Channel { get; init; }
public string? Template { get; init; }
public string? Locale { get; init; }
public bool? Enabled { get; init; }
}
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; }
}

View File

@@ -1,305 +1,305 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -1,30 +1,30 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> EventKinds { get; init; } = [];
public int MaxEvents { get; init; } = 1000;
public bool IncludeNonMatches { get; init; } = true;
public bool EvaluateThrottling { get; init; } = true;
public bool EvaluateQuietHours { get; init; } = true;
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> EventKinds { get; init; } = [];
public int MaxEvents { get; init; } = 1000;
public bool IncludeNonMatches { get; init; } = true;
public bool EvaluateThrottling { get; init; } = true;
public bool EvaluateQuietHours { get; init; } = true;
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -1,30 +1,118 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a template.
/// </summary>
public sealed record TemplateUpsertRequest
{
public string? Key { get; init; }
public string? Body { get; init; }
public string? Locale { get; init; }
public NotifyChannelType? ChannelType { get; init; }
public NotifyTemplateRenderMode? RenderMode { get; init; }
public NotifyDeliveryFormat? Format { get; init; }
public string? Description { get; init; }
public IEnumerable<KeyValuePair<string, string>>? Metadata { get; init; }
}
/// <summary>
/// Request for previewing a template render.
/// </summary>
public sealed record TemplatePreviewRequest
{
public JsonNode? SamplePayload { get; init; }
public bool? IncludeProvenance { get; init; }
public string? ProvenanceBaseUrl { get; init; }
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,348 +1,348 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}

View File

@@ -1,201 +1,201 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}

View File

@@ -1,15 +1,15 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}

View File

@@ -1,102 +1,102 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}

View File

@@ -1,273 +1,273 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}

View File

@@ -1,7 +1,7 @@
using System.Text;
namespace StellaOps.Notifier.WebService.Setup;
using System.Text;
namespace StellaOps.Notifier.WebService.Setup;
public sealed class OpenApiDocumentCache
{
private readonly string _document;
@@ -26,11 +26,11 @@ public sealed class OpenApiDocumentCache
_document = File.ReadAllText(path, Encoding.UTF8);
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
public string Document => _document;
public string Sha256 => _hash;

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Notifier.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;
namespace StellaOps.Notifier.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;

View File

@@ -1,17 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<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="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<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="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,254 +1,254 @@
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents/{incidentId}/ack:
post:
summary: Acknowledge an incident notification
@@ -256,64 +256,64 @@ paths:
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IncidentId'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
default:
$ref: '#/components/responses/Error'
@@ -414,124 +414,124 @@ components:
required: true
description: Stable UUID to dedupe retries.
schema: { type: string, format: uuid }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
Incident:
type: object
required: [incidentId, kind, severity, startedAt]
@@ -579,35 +579,35 @@ components:
labels:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,190 +1,190 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for CLI-based notification delivery.
/// Executes a configured command-line tool with notification payload as input.
/// Useful for custom integrations and local testing.
/// </summary>
public sealed class CliChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<CliChannelAdapter> _logger;
private readonly TimeSpan _commandTimeout;
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
}
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var command = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(command))
{
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
}
// Parse command and arguments
var (executable, arguments) = ParseCommand(command);
if (string.IsNullOrWhiteSpace(executable))
{
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
}
// Build JSON payload to send via stdin
var payload = new
{
bodyHash = rendered.BodyHash,
channel = rendered.ChannelType.ToString(),
target = rendered.Target,
title = rendered.Title,
body = rendered.Body,
summary = rendered.Summary,
textBody = rendered.TextBody,
format = rendered.Format.ToString(),
locale = rendered.Locale,
timestamp = DateTimeOffset.UtcNow.ToString("O"),
channelConfig = new
{
channelId = channel.ChannelId,
name = channel.Name,
properties = channel.Config?.Properties
}
};
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_commandTimeout);
var startInfo = new ProcessStartInfo
{
FileName = executable,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
// Add environment variables from channel config
if (channel.Config?.Properties is not null)
{
foreach (var kv in channel.Config.Properties)
{
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var envVar = kv.Key[4..];
startInfo.EnvironmentVariables[envVar] = kv.Value;
}
}
}
using var process = new Process { StartInfo = startInfo };
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
process.Start();
// Write payload to stdin
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
// Read output streams
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
var stdout = await outputTask.ConfigureAwait(false);
var stderr = await errorTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
_logger.LogInformation(
"CLI command executed successfully. Exit code: 0. Output: {Output}",
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
return ChannelDispatchResult.Ok(process.ExitCode);
}
_logger.LogWarning(
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
process.ExitCode,
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
// Non-zero exit codes are typically not retryable
return ChannelDispatchResult.Fail(
$"Exit code {process.ExitCode}: {stderr}",
process.ExitCode,
shouldRetry: false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
}
}
private static (string executable, string arguments) ParseCommand(string command)
{
command = command.Trim();
if (string.IsNullOrEmpty(command))
return (string.Empty, string.Empty);
// Handle quoted executable paths
if (command.StartsWith('"'))
{
var endQuote = command.IndexOf('"', 1);
if (endQuote > 0)
{
var exe = command[1..endQuote];
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
return (exe, args);
}
}
// Simple space-separated
var spaceIndex = command.IndexOf(' ');
if (spaceIndex > 0)
{
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
}
return (command, string.Empty);
}
}
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for CLI-based notification delivery.
/// Executes a configured command-line tool with notification payload as input.
/// Useful for custom integrations and local testing.
/// </summary>
public sealed class CliChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<CliChannelAdapter> _logger;
private readonly TimeSpan _commandTimeout;
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
}
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var command = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(command))
{
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
}
// Parse command and arguments
var (executable, arguments) = ParseCommand(command);
if (string.IsNullOrWhiteSpace(executable))
{
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
}
// Build JSON payload to send via stdin
var payload = new
{
bodyHash = rendered.BodyHash,
channel = rendered.ChannelType.ToString(),
target = rendered.Target,
title = rendered.Title,
body = rendered.Body,
summary = rendered.Summary,
textBody = rendered.TextBody,
format = rendered.Format.ToString(),
locale = rendered.Locale,
timestamp = DateTimeOffset.UtcNow.ToString("O"),
channelConfig = new
{
channelId = channel.ChannelId,
name = channel.Name,
properties = channel.Config?.Properties
}
};
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_commandTimeout);
var startInfo = new ProcessStartInfo
{
FileName = executable,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
// Add environment variables from channel config
if (channel.Config?.Properties is not null)
{
foreach (var kv in channel.Config.Properties)
{
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var envVar = kv.Key[4..];
startInfo.EnvironmentVariables[envVar] = kv.Value;
}
}
}
using var process = new Process { StartInfo = startInfo };
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
process.Start();
// Write payload to stdin
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
// Read output streams
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
var stdout = await outputTask.ConfigureAwait(false);
var stderr = await errorTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
_logger.LogInformation(
"CLI command executed successfully. Exit code: 0. Output: {Output}",
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
return ChannelDispatchResult.Ok(process.ExitCode);
}
_logger.LogWarning(
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
process.ExitCode,
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
// Non-zero exit codes are typically not retryable
return ChannelDispatchResult.Fail(
$"Exit code {process.ExitCode}: {stderr}",
process.ExitCode,
shouldRetry: false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
}
}
private static (string executable, string arguments) ParseCommand(string command)
{
command = command.Trim();
if (string.IsNullOrEmpty(command))
return (string.Empty, string.Empty);
// Handle quoted executable paths
if (command.StartsWith('"'))
{
var endQuote = command.IndexOf('"', 1);
if (endQuote > 0)
{
var exe = command[1..endQuote];
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
return (exe, args);
}
}
// Simple space-separated
var spaceIndex = command.IndexOf(' ');
if (spaceIndex > 0)
{
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
}
return (command, string.Empty);
}
}

View File

@@ -1,52 +1,378 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for email delivery. Requires SMTP configuration.
/// </summary>
public sealed class EmailChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<EmailChannelAdapter> _logger;
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var target = channel.Config?.Target ?? rendered.Target;
if (string.IsNullOrWhiteSpace(target))
{
return Task.FromResult(ChannelDispatchResult.Fail(
"Email recipient not configured",
shouldRetry: false));
}
// Email delivery requires SMTP integration which depends on environment config.
// For now, log the intent and return success for dev/test scenarios.
// Production deployments should integrate with an SMTP relay or email service.
_logger.LogInformation(
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
target,
rendered.Title,
rendered.Format);
// In a real implementation, this would:
// 1. Resolve SMTP settings from channel.Config.SecretRef
// 2. Build and send the email via SmtpClient or a service like SendGrid
// 3. Return actual success/failure based on delivery
return Task.FromResult(ChannelDispatchResult.Ok());
}
}
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);
}

View File

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

View File

@@ -1,51 +1,51 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Sends rendered notifications through a specific channel type.
/// </summary>
public interface INotifyChannelAdapter
{
/// <summary>
/// The channel type this adapter handles.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Sends a rendered notification through the channel.
/// </summary>
/// <param name="channel">The channel configuration.</param>
/// <param name="rendered">The rendered notification content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The dispatch result with status and any error details.</returns>
Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Sends rendered notifications through a specific channel type.
/// </summary>
public interface INotifyChannelAdapter
{
/// <summary>
/// The channel type this adapter handles.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Sends a rendered notification through the channel.
/// </summary>
/// <param name="channel">The channel configuration.</param>
/// <param name="rendered">The rendered notification content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The dispatch result with status and any error details.</returns>
Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}

Some files were not shown because too many files have changed in this diff Show More