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

@@ -658,6 +658,43 @@ static void ConfigureEndpoints(WebApplication app)
.RequireAuthorization(NotifyPolicies.Operator)
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
apiGroup.MapGet("/channels/{channelId}/health", async (
string channelId,
IChannelRepository repository,
INotifyChannelHealthService healthService,
HttpContext context,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!TryParseGuid(channelId, out var channelGuid))
{
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
}
var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken)
.ConfigureAwait(false);
if (channelEntity is null)
{
return Results.NotFound();
}
var channel = ToNotifyChannel(channelEntity);
var response = await healthService.CheckAsync(
tenant,
channel,
context.TraceIdentifier,
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
})
.WithName("NotifyGetChannelHealth")
.WithDescription("Returns connector diagnostics for the specified channel.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
@@ -1267,23 +1304,24 @@ static ChannelEntity ToChannelEntity(NotifyChannel channel)
static NotifyChannel ToNotifyChannel(ChannelEntity entity)
{
var config = string.IsNullOrWhiteSpace(entity.Config)
? NotifyChannelConfig.Create("inline-default")
: NotifyCanonicalJsonSerializer.Deserialize<NotifyChannelConfig>(entity.Config);
var metadataModel = TryDeserialize<NotifyChannel>(entity.Metadata);
if (metadataModel is not null)
{
return metadataModel;
}
return metadataModel ??
NotifyChannel.Create(
entity.Id.ToString(),
entity.TenantId,
entity.Name,
ToModelChannelType(entity.ChannelType),
config,
enabled: entity.Enabled,
createdBy: entity.CreatedBy,
createdAt: entity.CreatedAt,
updatedAt: entity.UpdatedAt);
var config = ToNotifyChannelConfig(entity);
return NotifyChannel.Create(
entity.Id.ToString(),
entity.TenantId,
entity.Name,
ToModelChannelType(entity.ChannelType),
config,
enabled: entity.Enabled,
createdBy: entity.CreatedBy,
createdAt: entity.CreatedAt,
updatedAt: entity.UpdatedAt);
}
static TemplateEntity ToTemplateEntity(NotifyTemplate template)
@@ -1497,6 +1535,108 @@ static T? TryDeserialize<T>(string? json)
}
}
static NotifyChannelConfig ToNotifyChannelConfig(ChannelEntity entity)
{
if (string.IsNullOrWhiteSpace(entity.Config))
{
return NotifyChannelConfig.Create("inline-default");
}
var config = TryDeserialize<NotifyChannelConfig>(entity.Config);
if (config is not null)
{
return config;
}
try
{
using var document = JsonDocument.Parse(entity.Config);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id));
}
var root = document.RootElement;
var secretRef = GetOptionalString(root, "secretRef");
var target = GetOptionalString(root, "target")
?? GetOptionalString(root, "channel")
?? GetOptionalString(root, "recipient");
var endpoint = GetOptionalString(root, "endpoint")
?? GetOptionalString(root, "webhookUrl")
?? GetOptionalString(root, "url");
var properties = ReadLegacyChannelProperties(root);
return NotifyChannelConfig.Create(
string.IsNullOrWhiteSpace(secretRef) ? BuildLegacyChannelSecretRef(entity.Id) : secretRef,
target,
endpoint,
properties.Count == 0 ? null : properties);
}
catch
{
return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id));
}
}
static string BuildLegacyChannelSecretRef(Guid channelId)
=> $"legacy://notify/channels/{channelId:N}";
static string? GetOptionalString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => property.GetString(),
_ => property.GetRawText()
};
}
static IReadOnlyList<KeyValuePair<string, string>> ReadLegacyChannelProperties(JsonElement root)
{
var properties = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("properties", out var embeddedProperties) && embeddedProperties.ValueKind == JsonValueKind.Object)
{
foreach (var property in embeddedProperties.EnumerateObject())
{
properties[property.Name] = GetJsonPropertyValue(property.Value);
}
}
foreach (var property in root.EnumerateObject())
{
if (IsCanonicalChannelConfigField(property.Name))
{
continue;
}
properties[property.Name] = GetJsonPropertyValue(property.Value);
}
return properties.ToArray();
}
static bool IsCanonicalChannelConfigField(string propertyName)
=> propertyName.Equals("secretRef", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("target", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("endpoint", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("properties", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("limits", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("channel", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("recipient", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("webhookUrl", StringComparison.OrdinalIgnoreCase)
|| propertyName.Equals("url", StringComparison.OrdinalIgnoreCase);
static string GetJsonPropertyValue(JsonElement element)
=> element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: element.GetRawText();
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
{
// Delegate to unified StellaOps tenant resolver (claims + canonical headers + legacy headers)

View File

@@ -2,6 +2,7 @@
using StellaOps.Notify.Models;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -44,6 +45,7 @@ public sealed record ChannelHealthResult(
/// <summary>
/// Supported channel health states.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChannelHealthStatus
{
Healthy,

View File

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
@@ -33,6 +34,7 @@ internal static class NotifyTestServiceOverrides
{
// Remove the Postgres data source that requires a real connection string
services.RemoveAll<NotifyDataSource>();
services.RemoveAll<IHostedService>();
// Replace repository registrations with in-memory implementations.
// Using singletons so data persists across scoped requests within a single test.

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
}