product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
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.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
|
||||
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.UseSetting("notify:storage:driver", "memory");
|
||||
builder.UseSetting("notify:authority:enabled", "false");
|
||||
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
|
||||
builder.UseSetting("notify:authority:issuer", Issuer);
|
||||
builder.UseSetting("notify:authority:audiences:0", Audience);
|
||||
builder.UseSetting("notify:authority:allowAnonymousFallback", "false");
|
||||
builder.UseSetting("notify:authority:adminScope", "notify.admin");
|
||||
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
|
||||
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
|
||||
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
||||
});
|
||||
|
||||
_viewerToken = CreateToken("notify.viewer");
|
||||
_operatorToken = CreateToken("notify.viewer", "notify.operator");
|
||||
_adminToken = CreateToken("notify.viewer", "notify.operator", "notify.admin");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region Health Endpoints Contract
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReturnsOkWithStatus()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/healthz");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
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");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rules Endpoints Contract
|
||||
|
||||
[Fact]
|
||||
public async Task ListRules_ReturnsJsonArray()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
json.Should().NotBeNull();
|
||||
json!.AsArray().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_ValidPayload_Returns201WithLocation()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
|
||||
|
||||
var ruleId = $"rule-contract-{Guid.NewGuid():N}";
|
||||
var payload = CreateRulePayload(ruleId);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// 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 = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _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 = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules/nonexistent-rule-id");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRule_Existing_Returns204()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
|
||||
|
||||
// First create a rule
|
||||
var ruleId = $"rule-delete-{Guid.NewGuid():N}";
|
||||
var payload = CreateRulePayload(ruleId);
|
||||
await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act
|
||||
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Channels Endpoints Contract
|
||||
|
||||
[Fact]
|
||||
public async Task ListChannels_ReturnsJsonArray()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/channels");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
json.Should().NotBeNull();
|
||||
json!.AsArray().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChannel_ValidPayload_Returns201()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
|
||||
|
||||
var channelId = $"channel-contract-{Guid.NewGuid():N}";
|
||||
var payload = CreateChannelPayload(channelId);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/channels",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Templates Endpoints Contract
|
||||
|
||||
[Fact]
|
||||
public async Task ListTemplates_ReturnsJsonArray()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/templates");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
json.Should().NotBeNull();
|
||||
json!.AsArray().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_ValidPayload_Returns201()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _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"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deliveries Endpoints Contract
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDelivery_ValidPayload_Returns201OrAccepted()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _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"));
|
||||
|
||||
// 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 = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
json.Should().NotBeNull();
|
||||
json!.AsArray().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDelivery_NotFound_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/api/v1/notify/deliveries/{Guid.NewGuid()}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Normalize Endpoints Contract (Internal)
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizeRule_ValidPayload_ReturnsUpgradedSchema()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||
|
||||
var payload = CreateRulePayload("rule-normalize-test");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/_internal/rules/normalize",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
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 = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
|
||||
|
||||
var ruleId = $"rule-shape-{Guid.NewGuid():N}";
|
||||
var payload = CreateRulePayload(ruleId);
|
||||
await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
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 = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
|
||||
|
||||
var channelId = $"channel-shape-{Guid.NewGuid():N}";
|
||||
var payload = CreateChannelPayload(channelId);
|
||||
await client.PostAsync(
|
||||
"/api/v1/notify/channels",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
|
||||
var response = await client.GetAsync($"/api/v1/notify/channels/{channelId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
|
||||
json?["channelId"].Should().NotBeNull();
|
||||
json?["tenantId"].Should().NotBeNull();
|
||||
json?["channelType"].Should().NotBeNull();
|
||||
json?["name"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string CreateToken(params string[] scopes)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, "test-user"),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new("tenant_id", TestTenantId)
|
||||
};
|
||||
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
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,
|
||||
["eventKinds"] = new JsonArray { "scan.completed" },
|
||||
["actions"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["actionId"] = $"action-{Guid.NewGuid():N}",
|
||||
["channel"] = "email:test",
|
||||
["templateKey"] = "default"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject CreateChannelPayload(string channelId)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "notify-channel@1",
|
||||
["channelId"] = channelId,
|
||||
["tenantId"] = TestTenantId,
|
||||
["channelType"] = "email",
|
||||
["name"] = $"Test Channel {channelId}",
|
||||
["enabled"] = true,
|
||||
["config"] = new JsonObject
|
||||
{
|
||||
["smtpHost"] = "localhost",
|
||||
["smtpPort"] = 25,
|
||||
["from"] = "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,
|
||||
["channelId"] = "email:default",
|
||||
["status"] = "pending",
|
||||
["recipient"] = "test@example.com",
|
||||
["subject"] = "Test Notification",
|
||||
["body"] = "This is a test notification."
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user