Files
git.stella-ops.org/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/W1/NotifyWebServiceContractTests.cs
master 0473a5876a fix(notify): normalize legacy channel config and restore health diagnostics endpoint
- Add legacy channel config normalization for unmapped smtpHost, webhookUrl,
  channel fields into canonical NotifyChannelConfig
- Restore GET /channels/{channelId}/health endpoint
- Add JsonConverter attribute to ChannelHealthStatus enum
- Add test coverage for legacy row shapes and health contract
- Remove hosted services from test override to isolate channel tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:53:33 +02:00

608 lines
21 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// W1 contract tests for Notify WebService endpoints.
/// Tests verify endpoint contracts (request/response shapes), status codes,
/// and OpenAPI compliance.
/// </summary>
public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory<Program>>, 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<Program> _factory;
private readonly string _operatorToken;
private readonly string _viewerToken;
private readonly string _adminToken;
public NotifyWebServiceContractTests(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"] = "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<string>().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<string>() == "legacy-slack");
legacyChannel.Should().NotBeNull();
legacyChannel!["config"]?["secretRef"]?.GetValue<string>().Should().Be("legacy://notify/channels/e00000010000000000000000000000aa");
legacyChannel["config"]?["target"]?.GetValue<string>().Should().Be("#security-alerts");
legacyChannel["config"]?["endpoint"]?.GetValue<string>().Should().Be("https://hooks.slack.example.com/services/demo");
legacyChannel["config"]?["properties"]?["username"]?.GetValue<string>().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<string>().Should().Be(channelId.ToString());
json["status"]?.GetValue<string>().Should().NotBeNullOrWhiteSpace();
json["metadata"]?["target"]?.GetValue<string>().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<string>().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<IChannelRepository>();
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
}