Files
git.stella-ops.org/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/CrudEndpointsTests.cs
2026-02-01 21:37:40 +02:00

448 lines
19 KiB
C#

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<WebApplicationFactory<Program>>, IAsyncLifetime
{
private const string SigningKey = "super-secret-test-key-1234567890";
private const string Issuer = "test-issuer";
private const string Audience = "notify";
private readonly WebApplicationFactory<Program> _factory;
private readonly string _operatorToken;
private readonly string _viewerToken;
public CrudEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<JsonObject>())
{
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<string>());
var single = await GetJsonObjectAsync(client, $"/api/v1/notify/rules/{ruleId}", useOperatorToken: false);
Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue<string>());
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<int>());
Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue<string>());
var deliveries = deliveriesEnvelope? ["items"] as JsonArray;
Assert.NotNull(deliveries);
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
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<string>());
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<JsonObject>(), entry => entry?["action"]?.GetValue<string>() == "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<bool>());
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<bool>());
}
[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<string>());
Assert.Equal(channelId, json["channelId"]?.GetValue<string>());
Assert.NotNull(json["queuedAt"]);
Assert.NotNull(json["traceId"]);
var preview = json["preview"]?.AsObject();
Assert.NotNull(preview);
Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue<string>());
Assert.Equal("Smoke test", preview? ["title"]?.GetValue<string>());
Assert.Equal("Sample body", preview? ["body"]?.GetValue<string>());
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant();
Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue<string>());
var metadata = json["metadata"] as JsonObject;
Assert.NotNull(metadata);
Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue<string>());
Assert.Equal("slack", metadata?["channelType"]?.GetValue<string>());
Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue<string>());
Assert.Equal(json["traceId"]?.GetValue<string>(), metadata?["traceId"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ChannelTestSendHonoursRateLimit()
{
using var limitedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<INotifyChannelTestProvider, FakeSlackTestProvider>();
});
});
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<string>());
Assert.Equal("Provider Title", preview?["title"]?.GetValue<string>());
Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue<string>());
var metadata = json["metadata"]?.AsObject();
Assert.NotNull(metadata);
Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue<string>());
Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue<string>());
}
private sealed class FakeSlackTestProvider : INotifyChannelTestProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
public Task<ChannelTestPreviewResult> 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<string, string>(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<JsonArray?> 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<JsonObject?> 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<HttpResponseMessage> 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<HttpResponseMessage> 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<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, bool useOperatorToken = true)
=> SendAsync(client, new HttpRequestMessage(method, path), useOperatorToken);
private Task<HttpResponseMessage> 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");
}
}