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>
This commit is contained in:
master
2026-03-09 07:53:33 +02:00
parent 481a062a1a
commit 0473a5876a
4 changed files with 246 additions and 17 deletions

View File

@@ -18,6 +18,8 @@ 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;
@@ -223,6 +225,84 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
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()
{
@@ -434,6 +514,13 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
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(
@@ -518,5 +605,3 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
}