// ----------------------------------------------------------------------------- // StellaOps.Notify.WebService.Tests / W1 / NotifyWebServiceContractTests.cs // W1 contract tests for Notify.WebService endpoints (send notification, query status) — OpenAPI snapshot. // Task: NOTIFY-5100-012 // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using StellaOps.Notify.Persistence.Postgres.Models; using StellaOps.Notify.Persistence.Postgres.Repositories; using Xunit; using Xunit.v3; namespace StellaOps.Notify.WebService.Tests.W1; /// /// W1 contract tests for Notify WebService endpoints. /// Tests verify endpoint contracts (request/response shapes), status codes, /// and OpenAPI compliance. /// public class NotifyWebServiceContractTests : IClassFixture>, IAsyncLifetime { private const string SigningKey = "super-secret-test-key-for-contract-tests-1234567890"; private const string Issuer = "test-issuer"; private const string Audience = "notify"; private const string TestTenantId = "tenant-contract-test"; private readonly WebApplicationFactory _factory; private readonly string _operatorToken; private readonly string _viewerToken; private readonly string _adminToken; public NotifyWebServiceContractTests(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"] = "false", ["notify:authority:adminScope"] = "notify.admin", ["notify:authority:operatorScope"] = "notify.operator", ["notify:authority:viewerScope"] = "notify.viewer", ["notify:telemetry:enableRequestLogging"] = "false", }); }); builder.ConfigureTestServices(services => { NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience); }); }); _viewerToken = CreateToken("notify.viewer"); _operatorToken = CreateToken("notify.viewer", "notify.operator"); _adminToken = CreateToken("notify.viewer", "notify.operator", "notify.admin"); } public ValueTask InitializeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; #region Health Endpoints Contract [Fact] public async Task HealthEndpoint_ReturnsOkWithStatus() { // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync("/healthz", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json?["status"]?.GetValue().Should().Be("ok"); } [Fact] public async Task ReadyEndpoint_ReturnsHealthStatus() { // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync("/readyz", CancellationToken.None); // Assert response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable); } #endregion #region Rules Endpoints Contract [Fact] public async Task ListRules_ReturnsJsonArray() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json.Should().NotBeNull(); json!.AsArray().Should().NotBeNull(); } [Fact] public async Task CreateRule_ValidPayload_Returns201WithLocation() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var ruleId = Guid.NewGuid().ToString(); var payload = CreateRulePayload(ruleId); // Act var response = await client.PostAsync( "/api/v1/notify/rules", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().Contain(ruleId); } [Fact] public async Task CreateRule_InvalidPayload_Returns400() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var invalidPayload = new JsonObject { ["invalid"] = "data" }; // Act var response = await client.PostAsync( "/api/v1/notify/rules", new StringContent(invalidPayload.ToJsonString(), Encoding.UTF8, "application/json")); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task GetRule_NotFound_Returns404() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync($"/api/v1/notify/rules/{Guid.NewGuid()}", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task DeleteRule_Existing_Returns204() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); // First create a rule var ruleId = Guid.NewGuid().ToString(); var payload = CreateRulePayload(ruleId); await client.PostAsync( "/api/v1/notify/rules", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Act var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.NoContent); } #endregion #region Channels Endpoints Contract [Fact] public async Task ListChannels_ReturnsJsonArray() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json.Should().NotBeNull(); json!.AsArray().Should().NotBeNull(); } [Fact] public async Task ListChannels_LegacyConfigWithoutSecretRef_ReturnsNormalizedChannel() { // Arrange await SeedChannelAsync(new ChannelEntity { Id = Guid.Parse("e0000001-0000-0000-0000-0000000000aa"), TenantId = TestTenantId, Name = "legacy-slack", ChannelType = ChannelType.Slack, Enabled = true, Config = """ { "channel": "#security-alerts", "username": "StellaOps", "webhookUrl": "https://hooks.slack.example.com/services/demo" } """, Metadata = "{}", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content)?.AsArray(); var legacyChannel = json? .Select(node => node?.AsObject()) .FirstOrDefault(node => node?["name"]?.GetValue() == "legacy-slack"); legacyChannel.Should().NotBeNull(); legacyChannel!["config"]?["secretRef"]?.GetValue().Should().Be("legacy://notify/channels/e00000010000000000000000000000aa"); legacyChannel["config"]?["target"]?.GetValue().Should().Be("#security-alerts"); legacyChannel["config"]?["endpoint"]?.GetValue().Should().Be("https://hooks.slack.example.com/services/demo"); legacyChannel["config"]?["properties"]?["username"]?.GetValue().Should().Be("StellaOps"); } [Fact] public async Task GetChannelHealth_ExistingChannel_ReturnsDiagnostics() { var channelId = Guid.Parse("e0000001-0000-0000-0000-0000000000ab"); await SeedChannelAsync(new ChannelEntity { Id = channelId, TenantId = TestTenantId, Name = "health-webhook", ChannelType = ChannelType.Webhook, Enabled = true, Config = """ { "secretRef": "secret://notify/health-webhook", "endpoint": "https://notify.example.test/hooks/demo" } """, Metadata = "{}", CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); var client = CreateAuthenticatedClient(_viewerToken); var response = await client.GetAsync($"/api/v1/notify/channels/{channelId}/health", CancellationToken.None); response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content)?.AsObject(); json.Should().NotBeNull(); json!["channelId"]?.GetValue().Should().Be(channelId.ToString()); json["status"]?.GetValue().Should().NotBeNullOrWhiteSpace(); json["metadata"]?["target"]?.GetValue().Should().Be("https://notify.example.test/hooks/demo"); } [Fact] public async Task CreateChannel_ValidPayload_Returns201() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var channelId = Guid.NewGuid().ToString(); var payload = CreateChannelPayload(channelId); // Act var response = await client.PostAsync( "/api/v1/notify/channels", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); } #endregion #region Templates Endpoints Contract [Fact] public async Task ListTemplates_ReturnsJsonArray() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync("/api/v1/notify/templates", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json.Should().NotBeNull(); json!.AsArray().Should().NotBeNull(); } [Fact] public async Task CreateTemplate_ValidPayload_Returns201() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var templateId = Guid.NewGuid(); var payload = CreateTemplatePayload(templateId); // Act var response = await client.PostAsync( "/api/v1/notify/templates", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); } #endregion #region Deliveries Endpoints Contract [Fact] public async Task CreateDelivery_ValidPayload_Returns201OrAccepted() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var deliveryId = Guid.NewGuid(); var payload = CreateDeliveryPayload(deliveryId); // Act var response = await client.PostAsync( "/api/v1/notify/deliveries", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Assert - can be 201 Created or 202 Accepted depending on processing response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Accepted); } [Fact] public async Task ListDeliveries_ReturnsJsonArrayWithPagination() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json.Should().NotBeNull(); } [Fact] public async Task GetDelivery_NotFound_Returns404() { // Arrange var client = CreateAuthenticatedClient(_viewerToken); // Act var response = await client.GetAsync($"/api/v1/notify/deliveries/{Guid.NewGuid()}", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.NotFound); } #endregion #region Normalize Endpoints Contract (Internal) [Fact] public async Task NormalizeRule_ValidPayload_ReturnsUpgradedSchema() { // Arrange var client = CreateAuthenticatedClient(_adminToken); var payload = CreateRulePayload(Guid.NewGuid().ToString()); // Act var response = await client.PostAsync( "/internal/notify/rules/normalize", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json?["schemaVersion"]?.GetValue().Should().NotBeNullOrEmpty(); } #endregion #region Response Shape Validation [Fact] public async Task RuleResponse_ContainsRequiredFields() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var ruleId = Guid.NewGuid().ToString(); var payload = CreateRulePayload(ruleId); await client.PostAsync( "/api/v1/notify/rules", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Act client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken); var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); // Verify required fields exist json?["ruleId"].Should().NotBeNull(); json?["tenantId"].Should().NotBeNull(); json?["schemaVersion"].Should().NotBeNull(); json?["name"].Should().NotBeNull(); json?["enabled"].Should().NotBeNull(); json?["actions"].Should().NotBeNull(); } [Fact] public async Task ChannelResponse_ContainsRequiredFields() { // Arrange var client = CreateAuthenticatedClient(_operatorToken); var channelId = Guid.NewGuid().ToString(); var payload = CreateChannelPayload(channelId); await client.PostAsync( "/api/v1/notify/channels", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"), CancellationToken.None); // Act client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken); var response = await client.GetAsync($"/api/v1/notify/channels/{channelId}", CancellationToken.None); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(CancellationToken.None); var json = JsonNode.Parse(content); json?["channelId"].Should().NotBeNull(); json?["tenantId"].Should().NotBeNull(); json?["type"].Should().NotBeNull(); json?["name"].Should().NotBeNull(); } #endregion #region Helper Methods private HttpClient CreateAuthenticatedClient(string token) { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId); return client; } private async Task SeedChannelAsync(ChannelEntity entity) { using var scope = _factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); await repository.CreateAsync(entity, CancellationToken.None); } private static string CreateToken(params string[] scopes) { return NotifyTestServiceOverrides.CreateTestToken( SigningKey, Issuer, Audience, scopes, tenantId: TestTenantId); } private static JsonObject CreateRulePayload(string ruleId) { return new JsonObject { ["schemaVersion"] = "notify.rule@1", ["ruleId"] = ruleId, ["tenantId"] = TestTenantId, ["name"] = $"Test Rule {ruleId}", ["description"] = "Contract test rule", ["enabled"] = true, ["match"] = new JsonObject { ["eventKinds"] = new JsonArray { "scan.completed" } }, ["actions"] = new JsonArray { new JsonObject { ["actionId"] = Guid.NewGuid().ToString(), ["channel"] = Guid.NewGuid().ToString(), ["template"] = "default", ["enabled"] = true } } }; } private static JsonObject CreateChannelPayload(string channelId) { return new JsonObject { ["schemaVersion"] = "notify.channel@1", ["channelId"] = channelId, ["tenantId"] = TestTenantId, ["type"] = "email", ["name"] = $"Test Channel {channelId}", ["enabled"] = true, ["config"] = new JsonObject { ["secretRef"] = "vault://notify/channels/test", ["target"] = "test@example.com" } }; } private static JsonObject CreateTemplatePayload(Guid templateId) { return new JsonObject { ["schemaVersion"] = "notify.template@1", ["templateId"] = templateId.ToString(), ["tenantId"] = TestTenantId, ["channelType"] = "email", ["key"] = "scan-report", ["locale"] = "en", ["body"] = "Scan completed for {{event.payload.image}}", ["renderMode"] = "markdown" }; } private static JsonObject CreateDeliveryPayload(Guid deliveryId) { return new JsonObject { ["deliveryId"] = deliveryId.ToString(), ["tenantId"] = TestTenantId, ["ruleId"] = Guid.NewGuid().ToString(), ["actionId"] = Guid.NewGuid().ToString(), ["eventId"] = Guid.NewGuid().ToString(), ["kind"] = "scanner.report.ready", ["status"] = "pending" }; } #endregion }