// ----------------------------------------------------------------------------- // 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 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 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 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 }