save checkpoint: save features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user