using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using StellaOps.Notify.Engine; using StellaOps.Notify.Models; using Xunit; using Xunit.v3; using StellaOps.TestKit; namespace StellaOps.Notify.WebService.Tests; public sealed class CrudEndpointsTests : IClassFixture>, IAsyncLifetime { private const string SigningKey = "super-secret-test-key-1234567890"; private const string Issuer = "test-issuer"; private const string Audience = "notify"; private readonly WebApplicationFactory _factory; private readonly string _operatorToken; private readonly string _viewerToken; public CrudEndpointsTests(WebApplicationFactory factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["notify:storage:driver"] = "memory", ["notify:authority:enabled"] = "false", ["notify:authority:developmentSigningKey"] = SigningKey, ["notify:authority:issuer"] = Issuer, ["notify:authority:audiences:0"] = Audience, ["notify:authority:allowAnonymousFallback"] = "true", ["notify:authority:adminScope"] = "notify.admin", ["notify:authority:operatorScope"] = "notify.operator", ["notify:authority:viewerScope"] = "notify.viewer", ["notify:telemetry:enableRequestLogging"] = "false", ["notify:api:rateLimits:testSend:tokenLimit"] = "10", ["notify:api:rateLimits:testSend:tokensPerPeriod"] = "10", ["notify:api:rateLimits:testSend:queueLimit"] = "5", ["notify:api:rateLimits:deliveryHistory:tokenLimit"] = "30", ["notify:api:rateLimits:deliveryHistory:tokensPerPeriod"] = "30", ["notify:api:rateLimits:deliveryHistory:queueLimit"] = "10", }); }); builder.ConfigureTestServices(services => { NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience); }); }); _operatorToken = CreateToken("notify.viewer", "notify.operator", "notify.admin"); _viewerToken = CreateToken("notify.viewer"); } public ValueTask InitializeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Trait("Category", TestCategories.Unit)] [Fact] public async Task RuleCrudLifecycle() { var client = _factory.CreateClient(); var payload = LoadSample("notify-rule@1.sample.json"); var ruleId = Guid.NewGuid().ToString(); payload["ruleId"] = ruleId; payload["tenantId"] = "tenant-web"; var actions = payload["actions"]!.AsArray(); foreach (var action in actions.OfType()) { action["actionId"] = Guid.NewGuid().ToString(); action["channel"] = Guid.NewGuid().ToString(); } await PostAsync(client, "/api/v1/notify/rules", payload); var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useOperatorToken: false); Assert.Equal(ruleId, list?[0]? ["ruleId"]?.GetValue()); var single = await GetJsonObjectAsync(client, $"/api/v1/notify/rules/{ruleId}", useOperatorToken: false); Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue()); await DeleteAsync(client, $"/api/v1/notify/rules/{ruleId}"); var afterDelete = await SendAsync(client, HttpMethod.Get, $"/api/v1/notify/rules/{ruleId}", useOperatorToken: false); Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ChannelTemplateDeliveryAndAuditFlows() { var client = _factory.CreateClient(); var channelId = Guid.NewGuid().ToString(); var channelPayload = LoadSample("notify-channel@1.sample.json"); channelPayload["channelId"] = channelId; channelPayload["tenantId"] = "tenant-web"; await PostAsync(client, "/api/v1/notify/channels", channelPayload); var templateId = Guid.NewGuid().ToString(); var templatePayload = LoadSample("notify-template@1.sample.json"); templatePayload["templateId"] = templateId; templatePayload["tenantId"] = "tenant-web"; await PostAsync(client, "/api/v1/notify/templates", templatePayload); var deliveryId = Guid.NewGuid().ToString(); var ruleId = Guid.NewGuid().ToString(); var delivery = NotifyDelivery.Create( deliveryId: deliveryId, tenantId: "tenant-web", ruleId: ruleId, actionId: channelId, eventId: Guid.NewGuid(), kind: NotifyEventKinds.ScannerReportReady, status: NotifyDeliveryStatus.Sent, createdAt: DateTimeOffset.UtcNow, sentAt: DateTimeOffset.UtcNow); var deliveryNode = JsonNode.Parse(NotifyCanonicalJsonSerializer.Serialize(delivery))!; await PostAsync(client, "/api/v1/notify/deliveries", deliveryNode); var deliveriesEnvelope = await GetJsonObjectAsync(client, "/api/v1/notify/deliveries?limit=10", useOperatorToken: false); Assert.NotNull(deliveriesEnvelope); Assert.Equal(1, deliveriesEnvelope? ["count"]?.GetValue()); Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue()); var deliveries = deliveriesEnvelope? ["items"] as JsonArray; Assert.NotNull(deliveries); Assert.NotEmpty(deliveries!.OfType()); var digestActionKey = "digest-key-test"; var digestRecipient = "test@example.com"; var digestNode = new JsonObject { ["channelId"] = channelId, ["recipient"] = digestRecipient, ["digestKey"] = digestActionKey, ["events"] = new JsonArray() }; await PostAsync(client, "/api/v1/notify/digests", digestNode); var digest = await GetJsonObjectAsync(client, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}", useOperatorToken: false); Assert.Equal(digestActionKey, digest? ["digestKey"]?.GetValue()); var auditPayload = JsonNode.Parse($$""" { "action": "create-rule", "entityType": "rule", "entityId": "{{ruleId}}", "payload": {"ruleId": "{{ruleId}}"} } """)!; await PostAsync(client, "/api/v1/notify/audit", auditPayload); var audits = await GetJsonArrayAsync(client, "/api/v1/notify/audit", useOperatorToken: false); Assert.NotNull(audits); Assert.Contains(audits!.OfType(), entry => entry?["action"]?.GetValue() == "create-rule"); await DeleteAsync(client, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}"); var digestAfterDelete = await SendAsync(client, HttpMethod.Get, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}", useOperatorToken: false); Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task LockEndpointsAllowAcquireAndRelease() { var client = _factory.CreateClient(); var acquirePayload = JsonNode.Parse(""" { "resource": "workers", "owner": "worker-1", "ttlSeconds": 30 } """)!; var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload); var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync(CancellationToken.None)); Assert.True(acquireContent? ["acquired"]?.GetValue()); await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse(""" { "resource": "workers", "owner": "worker-1" } """)!); var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload); var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync(CancellationToken.None)); Assert.True(secondContent? ["acquired"]?.GetValue()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ChannelTestSendReturnsPreview() { var client = _factory.CreateClient(); var channelId = Guid.NewGuid().ToString(); var channelPayload = LoadSample("notify-channel@1.sample.json"); channelPayload["channelId"] = channelId; channelPayload["tenantId"] = "tenant-web"; channelPayload["config"]! ["target"] = "#ops-alerts"; await PostAsync(client, "/api/v1/notify/channels", channelPayload); var payload = JsonNode.Parse(""" { "target": "#ops-alerts", "title": "Smoke test", "body": "Sample body" } """)!; var response = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject(); Assert.Equal("tenant-web", json["tenantId"]?.GetValue()); Assert.Equal(channelId, json["channelId"]?.GetValue()); Assert.NotNull(json["queuedAt"]); Assert.NotNull(json["traceId"]); var preview = json["preview"]?.AsObject(); Assert.NotNull(preview); Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue()); Assert.Equal("Smoke test", preview? ["title"]?.GetValue()); Assert.Equal("Sample body", preview? ["body"]?.GetValue()); var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant(); Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue()); var metadata = json["metadata"] as JsonObject; Assert.NotNull(metadata); Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue()); Assert.Equal("slack", metadata?["channelType"]?.GetValue()); Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue()); Assert.Equal(json["traceId"]?.GetValue(), metadata?["traceId"]?.GetValue()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ChannelTestSendHonoursRateLimit() { using var limitedFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["notify:api:rateLimits:testSend:tokenLimit"] = "1", ["notify:api:rateLimits:testSend:tokensPerPeriod"] = "1", ["notify:api:rateLimits:testSend:queueLimit"] = "0", }); }); }); var client = limitedFactory.CreateClient(); var channelId = Guid.NewGuid().ToString(); var channelPayload = LoadSample("notify-channel@1.sample.json"); channelPayload["channelId"] = channelId; channelPayload["tenantId"] = "tenant-web"; channelPayload["config"]! ["target"] = "#ops-alerts"; await PostAsync(client, "/api/v1/notify/channels", channelPayload); var payload = JsonNode.Parse(""" { "body": "First" } """)!; var first = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload); Assert.Equal(HttpStatusCode.Accepted, first.StatusCode); var secondRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{channelId}/test") { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; var second = await SendAsync(client, secondRequest); Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); Assert.NotNull(second.Headers.RetryAfter); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ChannelTestSendUsesRegisteredProvider() { var providerName = typeof(FakeSlackTestProvider).FullName!; using var providerFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { services.AddSingleton(); }); }); var client = providerFactory.CreateClient(); var channelId = Guid.NewGuid().ToString(); var channelPayload = LoadSample("notify-channel@1.sample.json"); channelPayload["channelId"] = channelId; channelPayload["tenantId"] = "tenant-web"; channelPayload["config"]! ["target"] = "#ops-alerts"; await PostAsync(client, "/api/v1/notify/channels", channelPayload); var payload = JsonNode.Parse(""" { "target": "#ops-alerts", "title": "Provider Title", "summary": "Provider Summary" } """)!; var response = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject(); var preview = json["preview"]?.AsObject(); Assert.NotNull(preview); Assert.Equal("#ops-alerts", preview?["target"]?.GetValue()); Assert.Equal("Provider Title", preview?["title"]?.GetValue()); Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue()); var metadata = json["metadata"]?.AsObject(); Assert.NotNull(metadata); Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue()); Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue()); } private sealed class FakeSlackTestProvider : INotifyChannelTestProvider { public NotifyChannelType ChannelType => NotifyChannelType.Slack; public Task BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var body = "{\"provider\":\"fake\"}"; var preview = NotifyDeliveryRendered.Create( NotifyChannelType.Slack, NotifyDeliveryFormat.Slack, context.Target, context.Request.Title ?? "Provider Title", body, context.Request.Summary ?? "Provider Summary", context.Request.TextBody, context.Request.Locale, ChannelTestPreviewUtilities.ComputeBodyHash(body), context.Request.Attachments); var metadata = new Dictionary(StringComparer.Ordinal) { ["provider.name"] = "fake-provider" }; return Task.FromResult(new ChannelTestPreviewResult(preview, metadata)); } } private static JsonNode LoadSample(string fileName) { var path = Path.Combine(AppContext.BaseDirectory, fileName); if (!File.Exists(path)) { throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path); } return JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException("Sample JSON null."); } private async Task GetJsonArrayAsync(HttpClient client, string path, bool useOperatorToken) { var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); return JsonNode.Parse(content) as JsonArray; } private async Task GetJsonObjectAsync(HttpClient client, string path, bool useOperatorToken) { var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); return JsonNode.Parse(content) as JsonObject; } private async Task PostAsync(HttpClient client, string path, JsonNode payload, bool useOperatorToken = true) { var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; var response = await SendAsync(client, request, useOperatorToken); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(CancellationToken.None); var authHeader = response.Headers.WwwAuthenticate.ToString(); throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body} (WWW-Authenticate: {authHeader})"); } return response; } private Task PostAsync(HttpClient client, string path, JsonNode payload) => PostAsync(client, path, payload, useOperatorToken: true); private async Task DeleteAsync(HttpClient client, string path) { var response = await SendAsync(client, HttpMethod.Delete, path); response.EnsureSuccessStatusCode(); } private Task SendAsync(HttpClient client, HttpMethod method, string path, bool useOperatorToken = true) => SendAsync(client, new HttpRequestMessage(method, path), useOperatorToken); private Task SendAsync(HttpClient client, HttpRequestMessage request, bool useOperatorToken = true) { request.Headers.Add("X-StellaOps-Tenant", "tenant-web"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useOperatorToken ? _operatorToken : _viewerToken); return client.SendAsync(request, CancellationToken.None); } private static string CreateToken(params string[] scopes) { return NotifyTestServiceOverrides.CreateTestToken( SigningKey, Issuer, Audience, scopes, tenantId: "tenant-web"); } }