save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -0,0 +1,337 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notifier.Tests.Channels;
public sealed class MultiChannelAdapterTests
{
[Fact]
public async Task SlackChannelAdapter_SendAsync_PostsBlockKitPayload()
{
var handler = new CapturingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("ok")
});
var client = new HttpClient(handler);
var adapter = new SlackChannelAdapter(client, NullLogger<SlackChannelAdapter>.Instance);
var channel = NotifyChannel.Create(
channelId: "chn-slack",
tenantId: "tenant-a",
name: "Slack",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "secret://slack",
target: "#ops",
endpoint: "https://hooks.slack.com/services/T/B/K"));
var rendered = NotifyDeliveryRendered.Create(
channelType: NotifyChannelType.Slack,
format: NotifyDeliveryFormat.Markdown,
target: "#ops",
title: "Alert",
body: "*deployment blocked*");
var result = await adapter.SendAsync(channel, rendered, CancellationToken.None);
Assert.True(result.Success);
Assert.Single(handler.RequestBodies);
Assert.Contains("\"blocks\":", handler.RequestBodies[0], StringComparison.Ordinal);
Assert.Contains("*deployment blocked*", handler.RequestBodies[0], StringComparison.Ordinal);
}
[Fact]
public async Task PagerDutyChannelAdapter_DispatchAsync_MapsSeverityAndSucceeds()
{
var handler = new CapturingHandler(_ => new HttpResponseMessage(HttpStatusCode.Accepted)
{
Content = new StringContent("""{"status":"success","dedup_key":"pd-dedup"}""", Encoding.UTF8, "application/json")
});
var client = new HttpClient(handler);
var audit = new InMemoryAuditRepository();
var options = Options.Create(new ChannelAdapterOptions { MaxRetries = 0 });
var adapter = new PagerDutyChannelAdapter(
client,
audit,
options,
TimeProvider.System,
NullLogger<PagerDutyChannelAdapter>.Instance,
() => 0.0);
var channel = NotifyChannel.Create(
channelId: "chn-pd",
tenantId: "tenant-a",
name: "PagerDuty",
type: NotifyChannelType.PagerDuty,
config: NotifyChannelConfig.Create(
secretRef: "secret://pd",
properties: new Dictionary<string, string> { ["routingKey"] = "rk-123" }));
var context = CreateContext(channel, "sev-critical", new Dictionary<string, string>
{
["severity"] = "critical",
["incidentId"] = "inc-001"
});
var result = await adapter.DispatchAsync(context, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(ChannelDispatchStatus.Sent, result.Status);
Assert.Contains("\"severity\":\"critical\"", handler.RequestBodies.Single(), StringComparison.Ordinal);
Assert.Equal("pd-dedup", result.ExternalId);
}
[Fact]
public async Task OpsGenieChannelAdapter_DispatchAsync_CreatesAlertWithPriority()
{
var handler = new CapturingHandler(_ => new HttpResponseMessage(HttpStatusCode.Accepted)
{
Content = new StringContent("""{"result":"Request will be processed","requestId":"og-req-1"}""", Encoding.UTF8, "application/json")
});
var client = new HttpClient(handler);
var audit = new InMemoryAuditRepository();
var options = Options.Create(new ChannelAdapterOptions { MaxRetries = 0 });
var adapter = new OpsGenieChannelAdapter(
client,
audit,
options,
TimeProvider.System,
NullLogger<OpsGenieChannelAdapter>.Instance,
() => 0.0);
var channel = NotifyChannel.Create(
channelId: "chn-og",
tenantId: "tenant-a",
name: "OpsGenie",
type: NotifyChannelType.OpsGenie,
config: NotifyChannelConfig.Create(
secretRef: "secret://og",
properties: new Dictionary<string, string> { ["apiKey"] = "og-key" }));
var context = CreateContext(channel, "sev-high", new Dictionary<string, string>
{
["severity"] = "high"
});
var result = await adapter.DispatchAsync(context, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(ChannelDispatchStatus.Sent, result.Status);
Assert.Contains("\"priority\":\"P2\"", handler.RequestBodies.Single(), StringComparison.Ordinal);
Assert.Contains("GenieKey", handler.AuthorizationHeaders.Single(), StringComparison.Ordinal);
}
[Fact]
public async Task InAppChannelAdapter_DispatchAsync_StoresUnreadNotification()
{
var audit = new InMemoryAuditRepository();
var options = Options.Create(new InAppChannelOptions { MaxNotificationsPerInbox = 10 });
var adapter = new InAppChannelAdapter(
audit,
options,
TimeProvider.System,
NullLogger<InAppChannelAdapter>.Instance);
var channel = NotifyChannel.Create(
channelId: "chn-inapp",
tenantId: "tenant-a",
name: "InApp",
type: NotifyChannelType.InApp,
config: NotifyChannelConfig.Create(secretRef: "secret://inapp"));
var context = CreateContext(channel, "inapp", new Dictionary<string, string>
{
["targetUserId"] = "operator-a",
["category"] = "release"
});
var result = await adapter.DispatchAsync(context, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(ChannelDispatchStatus.Sent, result.Status);
var unread = adapter.GetUnreadNotifications("tenant-a", "operator-a");
Assert.Single(unread);
Assert.Equal("release", unread[0].Category);
Assert.Equal("inapp", unread[0].Body);
}
[Fact]
public async Task EmailChannelAdapter_CheckHealthAsync_ValidatesConfigAndDisabledState()
{
var audit = new InMemoryAuditRepository();
var options = Options.Create(new ChannelAdapterOptions());
var adapter = new EmailChannelAdapter(
audit,
options,
TimeProvider.System,
NullLogger<EmailChannelAdapter>.Instance,
() => 0.0);
var validChannel = NotifyChannel.Create(
channelId: "chn-email",
tenantId: "tenant-a",
name: "Email",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "smtp-pass",
target: "ops@example.test",
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.example.test",
["smtpPort"] = "587",
["fromAddress"] = "noreply@example.test",
["enableSsl"] = "true"
}));
var healthy = await adapter.CheckHealthAsync(validChannel, CancellationToken.None);
Assert.True(healthy.Healthy);
Assert.Equal("healthy", healthy.Status);
var disabled = NotifyChannel.Create(
channelId: validChannel.ChannelId,
tenantId: validChannel.TenantId,
name: validChannel.Name,
type: validChannel.Type,
config: validChannel.Config,
displayName: validChannel.DisplayName,
description: validChannel.Description,
enabled: false,
labels: validChannel.Labels,
metadata: validChannel.Metadata,
createdBy: validChannel.CreatedBy,
createdAt: validChannel.CreatedAt,
updatedBy: validChannel.UpdatedBy,
updatedAt: validChannel.UpdatedAt,
schemaVersion: validChannel.SchemaVersion);
var degraded = await adapter.CheckHealthAsync(disabled, CancellationToken.None);
Assert.True(degraded.Healthy);
Assert.Equal("degraded", degraded.Status);
}
[Fact]
public async Task EmailChannelAdapter_DispatchAsync_MissingSmtpConfigFailsFast()
{
var audit = new InMemoryAuditRepository();
var options = Options.Create(new ChannelAdapterOptions { MaxRetries = 0 });
var adapter = new EmailChannelAdapter(
audit,
options,
TimeProvider.System,
NullLogger<EmailChannelAdapter>.Instance,
() => 0.0);
var invalidChannel = NotifyChannel.Create(
channelId: "chn-email-invalid",
tenantId: "tenant-a",
name: "Email",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "smtp-pass",
target: "ops@example.test",
properties: new Dictionary<string, string>
{
["fromAddress"] = "noreply@example.test"
}));
var context = CreateContext(invalidChannel, "body", new Dictionary<string, string>());
var result = await adapter.DispatchAsync(context, CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(ChannelDispatchStatus.InvalidConfiguration, result.Status);
}
[Fact]
public void ChannelAdapterFactory_ResolvesRegisteredAdaptersByType()
{
var slack = new StubChannelAdapter(NotifyChannelType.Slack);
var pagerDuty = new StubChannelAdapter(NotifyChannelType.PagerDuty);
var factory = new ChannelAdapterFactory(new IChannelAdapter[] { slack, pagerDuty });
Assert.Same(slack, factory.GetAdapter(NotifyChannelType.Slack));
Assert.Same(pagerDuty, factory.GetAdapter(NotifyChannelType.PagerDuty));
Assert.Null(factory.GetAdapter(NotifyChannelType.Email));
Assert.Equal(2, factory.GetAllAdapters().Count);
}
private static ChannelDispatchContext CreateContext(
NotifyChannel channel,
string body,
IReadOnlyDictionary<string, string> metadata)
{
var delivery = NotifyDelivery.Create(
deliveryId: $"del-{Guid.NewGuid():N}"[..16],
tenantId: channel.TenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "release.event",
status: NotifyDeliveryStatus.Pending);
return new ChannelDispatchContext(
DeliveryId: delivery.DeliveryId,
TenantId: channel.TenantId,
Channel: channel,
Delivery: delivery,
RenderedBody: body,
Subject: "Release update",
Metadata: metadata,
Timestamp: DateTimeOffset.UtcNow,
TraceId: "trace-001");
}
private sealed class StubChannelAdapter : IChannelAdapter
{
public StubChannelAdapter(NotifyChannelType channelType)
{
ChannelType = channelType;
}
public NotifyChannelType ChannelType { get; }
public Task<ChannelDispatchResult> DispatchAsync(ChannelDispatchContext context, CancellationToken cancellationToken)
=> Task.FromResult(ChannelDispatchResult.Ok());
public Task<ChannelHealthCheckResult> CheckHealthAsync(NotifyChannel channel, CancellationToken cancellationToken)
=> Task.FromResult(ChannelHealthCheckResult.Ok());
}
private sealed class CapturingHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public CapturingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public List<string> RequestBodies { get; } = [];
public List<string> AuthorizationHeaders { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (request.Content is not null)
{
RequestBodies.Add(await request.Content.ReadAsStringAsync(cancellationToken));
}
if (request.Headers.Authorization is AuthenticationHeaderValue auth)
{
AuthorizationHeaders.Add($"{auth.Scheme} {auth.Parameter}");
}
return _responseFactory(request);
}
}
}

View File

@@ -0,0 +1,155 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Contracts;
using Xunit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class DigestThrottleApiBehaviorTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public DigestThrottleApiBehaviorTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task ThrottleConfigs_require_tenant_header()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/notify/throttle-configs", CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ThrottleConfigs_support_put_list_get_delete_lifecycle()
{
var client = CreateTenantClient("tenant-throttle");
const string configId = "digest-default";
var upsert = new ThrottleConfigUpsertRequest
{
Name = "Digest Default",
DefaultWindow = TimeSpan.FromMinutes(5),
MaxNotificationsPerWindow = 3,
IsDefault = true,
Enabled = true
};
var putResponse = await client.PutAsJsonAsync($"/api/v2/notify/throttle-configs/{configId}", upsert, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
var getResponse = await client.GetAsync($"/api/v2/notify/throttle-configs/{configId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var getBody = await getResponse.Content.ReadAsStringAsync(CancellationToken.None);
Assert.Contains(configId, getBody, StringComparison.Ordinal);
var listResponse = await client.GetAsync("/api/v2/notify/throttle-configs", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(listJson.TryGetProperty("count", out var count));
Assert.True(count.GetInt32() >= 1);
var listBody = await listResponse.Content.ReadAsStringAsync(CancellationToken.None);
Assert.Contains(configId, listBody, StringComparison.Ordinal);
var deleteResponse = await client.DeleteAsync($"/api/v2/notify/throttle-configs/{configId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var afterDelete = await client.GetAsync($"/api/v2/notify/throttle-configs/{configId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
}
[Fact]
public async Task QuietHours_reject_invalid_payload_and_accept_valid_schedule()
{
var client = CreateTenantClient("tenant-quiet-hours");
const string scheduleId = "digest-quiet-hours";
var invalid = new QuietHoursUpsertRequest
{
Name = "Invalid",
CronExpression = "0 0 * * *",
Duration = TimeSpan.Zero,
TimeZone = "UTC"
};
var invalidResponse = await client.PutAsJsonAsync($"/api/v2/notify/quiet-hours/{scheduleId}", invalid, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, invalidResponse.StatusCode);
var valid = new QuietHoursUpsertRequest
{
Name = "Night",
CronExpression = "0 22 * * *",
Duration = TimeSpan.FromHours(8),
TimeZone = "UTC",
Enabled = true
};
var validResponse = await client.PutAsJsonAsync($"/api/v2/notify/quiet-hours/{scheduleId}", valid, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode);
var listResponse = await client.GetAsync("/api/v2/notify/quiet-hours", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listBody = await listResponse.Content.ReadAsStringAsync(CancellationToken.None);
Assert.Contains(scheduleId, listBody, StringComparison.Ordinal);
}
[Fact]
public async Task OperatorOverrides_validate_type_and_support_lifecycle()
{
var client = CreateTenantClient("tenant-overrides");
var invalid = new OperatorOverrideCreateRequest
{
OverrideType = "NotAType",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Reason = "invalid"
};
var invalidResponse = await client.PostAsJsonAsync("/api/v2/notify/overrides", invalid, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, invalidResponse.StatusCode);
var valid = new OperatorOverrideCreateRequest
{
OverrideType = "BypassThrottle",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(2),
Reason = "incident mitigation"
};
var created = await client.PostAsJsonAsync("/api/v2/notify/overrides", valid, cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.NotNull(created.Headers.Location);
var createdJson = await created.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(createdJson.TryGetProperty("overrideId", out var overrideIdProp));
var overrideId = overrideIdProp.GetString();
Assert.False(string.IsNullOrWhiteSpace(overrideId));
var getResponse = await client.GetAsync($"/api/v2/notify/overrides/{overrideId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var activeListResponse = await client.GetAsync("/api/v2/notify/overrides?activeOnly=true", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, activeListResponse.StatusCode);
var activeListBody = await activeListResponse.Content.ReadAsStringAsync(CancellationToken.None);
Assert.Contains(overrideId!, activeListBody, StringComparison.Ordinal);
var deleteResponse = await client.DeleteAsync($"/api/v2/notify/overrides/{overrideId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var afterDelete = await client.GetAsync($"/api/v2/notify/overrides/{overrideId}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "qa-agent");
return client;
}
}

View File

@@ -0,0 +1,173 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class SimulationEndpointsBehaviorTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public SimulationEndpointsBehaviorTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Simulate_requires_tenant_context()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/v2/simulate",
new
{
events = new[]
{
new { kind = "policy.violation" }
}
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Simulate_evaluates_rules_and_does_not_persist_rules()
{
var client = CreateTenantClient("tenant-sim");
var eventId = Guid.NewGuid();
_factory.ChannelRepo.Seed(
"tenant-sim",
NotifyChannel.Create(
channelId: "sim-channel",
tenantId: "tenant-sim",
name: "Simulation Channel",
type: NotifyChannelType.Custom,
config: NotifyChannelConfig.Create(secretRef: "ref://notify/channels/sim"),
enabled: true));
var baselineRulesResponse = await client.GetAsync("/api/v2/notify/rules", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, baselineRulesResponse.StatusCode);
var baselineRulesJson = await baselineRulesResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(JsonValueKind.Array, baselineRulesJson.ValueKind);
var baselineRuleCount = baselineRulesJson.GetArrayLength();
var simulateResponse = await client.PostAsJsonAsync(
"/api/v2/simulate",
new
{
tenantId = "tenant-sim",
includeNonMatches = true,
events = new[]
{
new
{
eventId,
kind = "policy.violation",
tenantId = "tenant-sim",
attributes = new Dictionary<string, string>
{
["severity"] = "critical",
["verdict"] = "fail"
}
}
},
rules = new[]
{
new
{
ruleId = "sim-rule-001",
tenantId = "tenant-sim",
name = "Critical Policy Rule",
enabled = true,
match = new
{
eventKinds = new[] { "policy.violation" },
minSeverity = "high",
verdicts = new[] { "fail" }
},
actions = new[]
{
new
{
actionId = "sim-action-001",
channel = "sim-channel",
enabled = true
}
}
}
}
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, simulateResponse.StatusCode);
var simulateJson = await simulateResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(simulateJson.TryGetProperty("totalEvents", out var totalEvents));
Assert.Equal(1, totalEvents.GetInt32());
Assert.True(simulateJson.TryGetProperty("matchedEvents", out var matchedEvents));
Assert.Equal(1, matchedEvents.GetInt32());
Assert.True(simulateJson.TryGetProperty("totalActionsTriggered", out var totalActionsTriggered));
Assert.Equal(1, totalActionsTriggered.GetInt32());
var rulesResponse = await client.GetAsync("/api/v2/notify/rules", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, rulesResponse.StatusCode);
var rulesJson = await rulesResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(JsonValueKind.Array, rulesJson.ValueKind);
Assert.Equal(baselineRuleCount, rulesJson.GetArrayLength());
}
[Fact]
public async Task ValidateRule_returns_warning_for_disabled_rule()
{
var client = CreateTenantClient("tenant-validate");
var validateResponse = await client.PostAsJsonAsync(
"/api/v2/simulate/validate",
new
{
tenantId = "tenant-validate",
ruleId = "validate-rule-001",
name = "Disabled Rule",
enabled = false,
match = new
{
eventKinds = new[] { "policy.violation" },
minSeverity = "high"
},
actions = new[]
{
new
{
actionId = "act-001",
channel = "slack:alerts",
enabled = true
}
}
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, validateResponse.StatusCode);
var validateJson = await validateResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(validateJson.TryGetProperty("isValid", out var isValid));
Assert.True(isValid.GetBoolean());
Assert.True(validateJson.TryGetProperty("warnings", out var warnings));
Assert.Contains(
warnings.EnumerateArray(),
warning => warning.TryGetProperty("code", out var code) && string.Equals(code.GetString(), "rule_disabled", StringComparison.Ordinal));
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "qa-agent");
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
return client;
}
}

View File

@@ -0,0 +1,47 @@
extern alias webservice;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.Worker.Security;
using WebProgram = webservice::Program;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class StartupDependencyWiringTests
{
[Fact]
public void WebServiceStartup_ResolvesCorrelationAndAckDependencies()
{
using var factory = new WebApplicationFactory<WebProgram>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["notifier:security:ackToken:SigningKey"] = "test-signing-key-0123456789abcdef",
["notifier:security:ackToken:BaseUrl"] = "https://notifier.test.local"
});
});
builder.UseDefaultServiceProvider((_, options) =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
});
using var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
Assert.NotNull(services.GetRequiredService<ICryptoHmac>());
Assert.NotNull(services.GetRequiredService<IIncidentManager>());
Assert.NotNull(services.GetRequiredService<IAckTokenService>());
Assert.NotNull(services.GetRequiredService<IAckBridge>());
}
}

View File

@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.TestKit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class StormBreakerEndpointsBehaviorTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public StormBreakerEndpointsBehaviorTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task NotifyStormEndpoints_ListAndSummary_ReturnSeededStorm()
{
const string tenantId = "tenant-storm";
const string stormKey = "digest:service-a";
await SeedStormAsync(tenantId, stormKey);
using var client = CreateTenantClient(tenantId);
var listResponse = await client.GetAsync("/api/v2/notify/storms", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listBody = await listResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(listBody.TryGetProperty("count", out var countElement));
Assert.True(countElement.GetInt32() >= 1);
Assert.Contains(
listBody.GetProperty("items").EnumerateArray(),
item =>
item.TryGetProperty("tenantId", out var tenantElement) &&
item.TryGetProperty("stormKey", out var keyElement) &&
string.Equals(tenantElement.GetString(), tenantId, StringComparison.Ordinal) &&
string.Equals(keyElement.GetString(), stormKey, StringComparison.Ordinal));
var summaryResponse = await client.PostAsync(
$"/api/v2/notify/storms/{Uri.EscapeDataString(stormKey)}/summary",
content: null,
cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, summaryResponse.StatusCode);
var summaryBody = await summaryResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(tenantId, summaryBody.GetProperty("tenantId").GetString());
Assert.Equal(stormKey, summaryBody.GetProperty("stormKey").GetString());
Assert.True(summaryBody.GetProperty("totalEvents").GetInt32() >= 10);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task NotifyStormEndpoints_RejectMissingTenantAndUnknownStorm()
{
using var anonymousClient = _factory.CreateClient();
var missingTenantResponse = await anonymousClient.GetAsync("/api/v2/notify/storms", CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, missingTenantResponse.StatusCode);
using var tenantClient = CreateTenantClient("tenant-storm-negative");
var missingStormResponse = await tenantClient.PostAsync(
"/api/v2/notify/storms/nonexistent/summary",
content: null,
cancellationToken: CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, missingStormResponse.StatusCode);
}
private async Task SeedStormAsync(string tenantId, string stormKey)
{
using var scope = _factory.Services.CreateScope();
var stormBreaker = scope.ServiceProvider.GetRequiredService<IStormBreaker>();
var stormTriggered = false;
for (var i = 0; i < 512; i++)
{
var result = await stormBreaker.EvaluateAsync(
tenantId,
stormKey,
$"{stormKey}-event-{i}",
CancellationToken.None);
if (result.IsStorm)
{
stormTriggered = true;
break;
}
}
Assert.True(stormTriggered);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "qa-agent");
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
return client;
}
}

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.TestKit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class StormBreakerEndpointsTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public StormBreakerEndpointsTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task StormBreakerEndpoints_RequireTenantHeader()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/storm-breaker/storms", CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task StormBreakerEndpoints_ListGetSummaryAndClear_RoundTrip()
{
const string tenantId = "tenant-storm";
const string stormKey = "policy.violation";
await SeedStormAsync(tenantId, stormKey);
using var client = CreateTenantClient(tenantId);
var listResponse = await client.GetAsync("/api/v2/storm-breaker/storms", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listPayload = await listResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(listPayload.TryGetProperty("count", out var count));
Assert.True(count.GetInt32() >= 1);
var getResponse = await client.GetAsync($"/api/v2/storm-breaker/storms/{stormKey}", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var getPayload = await getResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(tenantId, getPayload.GetProperty("tenantId").GetString());
Assert.Equal(stormKey, getPayload.GetProperty("stormKey").GetString());
Assert.True(getPayload.GetProperty("eventCount").GetInt32() >= 10);
var summaryResponse = await client.PostAsync($"/api/v2/storm-breaker/storms/{stormKey}/summary", null, CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, summaryResponse.StatusCode);
var summaryPayload = await summaryResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(tenantId, summaryPayload.GetProperty("tenantId").GetString());
Assert.Equal(stormKey, summaryPayload.GetProperty("stormKey").GetString());
Assert.True(summaryPayload.GetProperty("totalEvents").GetInt32() >= 10);
var clearResponse = await client.DeleteAsync($"/api/v2/storm-breaker/storms/{stormKey}", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, clearResponse.StatusCode);
var getAfterClearResponse = await client.GetAsync($"/api/v2/storm-breaker/storms/{stormKey}", CancellationToken.None);
Assert.Equal(HttpStatusCode.NotFound, getAfterClearResponse.StatusCode);
}
private async Task SeedStormAsync(string tenantId, string stormKey)
{
using var scope = _factory.Services.CreateScope();
var breaker = scope.ServiceProvider.GetRequiredService<IStormBreaker>();
for (var i = 0; i < 256; i++)
{
var result = await breaker.EvaluateAsync(tenantId, stormKey, $"event-{i}", CancellationToken.None);
if (result.IsStorm)
{
return;
}
}
throw new InvalidOperationException("Failed to trigger storm state during test seeding.");
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-Actor", "qa-agent");
return client;
}
}

View File

@@ -0,0 +1,203 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Notifier.Tests.Support;
using StellaOps.TestKit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class SuppressionEndpointsTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public SuppressionEndpointsTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ThrottleConfigurationEndpoints_RoundTripAndEvaluate_ReturnExpectedDurations()
{
using var client = CreateSuppressionClient();
var updateResponse = await client.PutAsJsonAsync(
"/api/v2/throttles/config",
new
{
defaultDurationSeconds = 120,
eventKindOverrides = new Dictionary<string, int>
{
["scanner.report.ready"] = 30
},
maxEventsPerWindow = 3,
burstWindowDurationSeconds = 60,
enabled = true
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var getResponse = await client.GetAsync("/api/v2/throttles/config", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var getBody = await getResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal("tenant-suppression", getBody.GetProperty("tenantId").GetString());
Assert.Equal(120, getBody.GetProperty("defaultDurationSeconds").GetInt32());
Assert.Equal(30, getBody.GetProperty("eventKindOverrides").GetProperty("scanner.report.ready").GetInt32());
var evaluateOverride = await client.PostAsJsonAsync(
"/api/v2/throttles/evaluate",
new
{
eventKind = "scanner.report.ready.critical"
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, evaluateOverride.StatusCode);
var overrideBody = await evaluateOverride.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(30, overrideBody.GetProperty("effectiveDurationSeconds").GetInt32());
var evaluateDefault = await client.PostAsJsonAsync(
"/api/v2/throttles/evaluate",
new
{
eventKind = "scanner.other.event"
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, evaluateDefault.StatusCode);
var defaultBody = await evaluateDefault.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(120, defaultBody.GetProperty("effectiveDurationSeconds").GetInt32());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ThrottleConfigurationDelete_RevertsToDefaultDuration()
{
using var client = CreateSuppressionClient();
var updateResponse = await client.PutAsJsonAsync(
"/api/v2/throttles/config",
new
{
defaultDurationSeconds = 45,
enabled = true
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var deleteResponse = await client.DeleteAsync("/api/v2/throttles/config", CancellationToken.None);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var evaluateAfterDelete = await client.PostAsJsonAsync(
"/api/v2/throttles/evaluate",
new
{
eventKind = "scanner.report.ready"
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, evaluateAfterDelete.StatusCode);
var evaluateBody = await evaluateAfterDelete.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.Equal(900, evaluateBody.GetProperty("effectiveDurationSeconds").GetInt32());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task QuietHoursAndOperatorOverrideEndpoints_SupportSuppressionAndBypassChecks()
{
using var client = CreateSuppressionClient();
var evaluationTime = DateTimeOffset.UtcNow;
var currentDay = (int)evaluationTime.DayOfWeek;
var createCalendarResponse = await client.PostAsJsonAsync(
"/api/v2/quiet-hours/calendars",
new
{
name = "Always On",
enabled = true,
priority = 10,
schedules = new[]
{
new
{
name = "All Day",
startTime = "00:00",
endTime = "23:59",
daysOfWeek = new[] { currentDay },
timezone = "UTC",
enabled = true
}
}
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.Created, createCalendarResponse.StatusCode);
var quietHoursEvaluateResponse = await client.PostAsJsonAsync(
"/api/v2/quiet-hours/evaluate",
new
{
eventKind = "scanner.report.ready",
evaluationTime
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, quietHoursEvaluateResponse.StatusCode);
var quietBody = await quietHoursEvaluateResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(quietBody.GetProperty("isActive").GetBoolean());
var createOverrideResponse = await client.PostAsJsonAsync(
"/api/v2/overrides/",
new
{
actor = "qa-user",
type = "throttle",
reason = "urgent release window bypass",
durationMinutes = 30,
eventKinds = new[] { "scanner.report.ready" },
correlationKeys = new[] { "digest:service-a" }
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.Created, createOverrideResponse.StatusCode);
var checkOverrideResponse = await client.PostAsJsonAsync(
"/api/v2/overrides/check",
new
{
eventKind = "scanner.report.ready.critical",
correlationKey = "digest:service-a"
},
CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, checkOverrideResponse.StatusCode);
var overrideBody = await checkOverrideResponse.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
Assert.True(overrideBody.GetProperty("hasOverride").GetBoolean());
Assert.Contains(
overrideBody.GetProperty("bypassedTypes").EnumerateArray().Select(x => x.GetString()),
value => string.Equals(value, "throttle", StringComparison.OrdinalIgnoreCase));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ThrottleEndpoints_RejectMissingTenantHeader()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/throttles/config", CancellationToken.None);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private HttpClient CreateSuppressionClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-suppression");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-suppression");
client.DefaultRequestHeaders.Add("X-Actor", "qa-user");
return client;
}
}

View File

@@ -1,11 +1,22 @@
# StellaOps.Notifier.Tests Task Board
# StellaOps.Notifier.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-NOTIFIER-VERIFY-001 | DONE | `ack-tokens-for-approval-workflows` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/ack-tokens-for-approval-workflows.md`. |
| QA-NOTIFIER-VERIFY-002 | DONE | `digest-windows-and-throttling` run-002 passed Tier 0/1/2 after suppression endpoint DI fix + new endpoint E2E coverage; moved to `docs/features/checked/notifier/digest-windows-and-throttling.md`. |
| QA-NOTIFIER-VERIFY-003 | DONE | `multi-channel-delivery` run-003 passed Tier 0/1/2 after compile remediation; moved to `docs/features/checked/notifier/multi-channel-delivery.md`. |
| QA-NOTIFIER-VERIFY-004 | DONE | `notification-correlation-engine` run-002 recheck passed Tier 0/1/2 with fresh API/integration evidence; fixed WebService startup DI (`IIncidentManager`, `ICryptoHmac`) and added `StartupDependencyWiringTests` guard. |
| QA-NOTIFIER-VERIFY-005 | DONE | `notification-digest-generator` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/notification-digest-generator.md`. |
| QA-NOTIFIER-VERIFY-006 | DONE | `notification-rules-engine` run-002 passed Tier 0/1/2 after simulation DI + endpoint behavior test remediation; moved to `docs/features/checked/notifier/notification-rules-engine.md`. |
| QA-NOTIFIER-VERIFY-007 | DONE | `notification-storm-breaker` run-001 passed Tier 0/1/2 with new storm-breaker endpoint behavior coverage and moved to `docs/features/checked/notifier/notification-storm-breaker.md`. |
| AUDIT-0394-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Notifier.Tests. |
| AUDIT-0394-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notifier.Tests. |
| AUDIT-0394-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -26,6 +26,7 @@ using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.Worker.Tenancy;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notifier.Worker.Correlation;
using DeadLetterStatus = StellaOps.Notifier.Worker.DeadLetter.DeadLetterStatus;
using Contracts = StellaOps.Notifier.WebService.Contracts;
using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService;
@@ -33,6 +34,7 @@ using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplat
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Cryptography;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Router.AspNet;
@@ -45,6 +47,14 @@ builder.Configuration
.AddEnvironmentVariables(prefix: "NOTIFIER_");
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
// Core correlation engine registrations required by incident and escalation flows.
builder.Services.AddCorrelationServices(builder.Configuration);
// Rule evaluation + simulation services power /api/v2/simulate* endpoints.
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
StellaOps.Notifier.Worker.Simulation.SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
@@ -67,6 +77,14 @@ builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMainte
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
// Correlation suppression services backing /api/v2/throttles, /api/v2/quiet-hours, /api/v2/overrides.
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
// Template service with enhanced renderer (worker contracts)
builder.Services.AddTemplateServices(options =>
{

View File

@@ -1,11 +1,22 @@
# StellaOps.Notifier.WebService Task Board
# StellaOps.Notifier.WebService Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-NOTIFIER-VERIFY-001 | DONE | `ack-tokens-for-approval-workflows` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/ack-tokens-for-approval-workflows.md`. |
| QA-NOTIFIER-VERIFY-002 | DONE | `digest-windows-and-throttling` run-002 passed Tier 0/1/2 after suppression endpoint DI fix + new endpoint E2E coverage; moved to `docs/features/checked/notifier/digest-windows-and-throttling.md`. |
| QA-NOTIFIER-VERIFY-003 | DONE | `multi-channel-delivery` run-003 passed Tier 0/1/2 after compile remediation; moved to `docs/features/checked/notifier/multi-channel-delivery.md`. |
| QA-NOTIFIER-VERIFY-004 | DONE | `notification-correlation-engine` run-002 recheck passed Tier 0/1/2 with fresh API/integration evidence; fixed WebService startup DI (`IIncidentManager`, `ICryptoHmac`) and added `StartupDependencyWiringTests` guard. |
| QA-NOTIFIER-VERIFY-005 | DONE | `notification-digest-generator` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/notification-digest-generator.md`. |
| QA-NOTIFIER-VERIFY-006 | DONE | `notification-rules-engine` run-002 passed Tier 0/1/2 after simulation DI + endpoint behavior test remediation; moved to `docs/features/checked/notifier/notification-rules-engine.md`. |
| QA-NOTIFIER-VERIFY-007 | DONE | `notification-storm-breaker` run-001 passed Tier 0/1/2 with new storm-breaker endpoint behavior coverage and moved to `docs/features/checked/notifier/notification-storm-breaker.md`. |
| AUDIT-0395-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Notifier.WebService. |
| AUDIT-0395-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notifier.WebService. |
| AUDIT-0395-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,11 +1,22 @@
# StellaOps.Notifier.Worker Task Board
# StellaOps.Notifier.Worker Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| QA-NOTIFIER-VERIFY-001 | DONE | `ack-tokens-for-approval-workflows` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/ack-tokens-for-approval-workflows.md`. |
| QA-NOTIFIER-VERIFY-002 | DONE | `digest-windows-and-throttling` run-002 passed Tier 0/1/2 after suppression endpoint DI fix + new endpoint E2E coverage; moved to `docs/features/checked/notifier/digest-windows-and-throttling.md`. |
| QA-NOTIFIER-VERIFY-003 | DONE | `multi-channel-delivery` run-003 passed Tier 0/1/2 after compile remediation; moved to `docs/features/checked/notifier/multi-channel-delivery.md`. |
| QA-NOTIFIER-VERIFY-004 | DONE | `notification-correlation-engine` run-002 recheck passed Tier 0/1/2 with fresh API/integration evidence; fixed WebService startup DI (`IIncidentManager`, `ICryptoHmac`) and added `StartupDependencyWiringTests` guard. |
| QA-NOTIFIER-VERIFY-005 | DONE | `notification-digest-generator` run-001 passed Tier 0/1/2 and moved to `docs/features/checked/notifier/notification-digest-generator.md`. |
| QA-NOTIFIER-VERIFY-006 | DONE | `notification-rules-engine` run-002 passed Tier 0/1/2 after simulation DI + endpoint behavior test remediation; moved to `docs/features/checked/notifier/notification-rules-engine.md`. |
| QA-NOTIFIER-VERIFY-007 | DONE | `notification-storm-breaker` run-001 passed Tier 0/1/2 with new storm-breaker endpoint behavior coverage and moved to `docs/features/checked/notifier/notification-storm-breaker.md`. |
| AUDIT-0396-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Notifier.Worker. |
| AUDIT-0396-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notifier.Worker. |
| AUDIT-0396-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |