From 0473a5876a87c79d0a9ae87aa450bb866790073a Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 9 Mar 2026 07:53:33 +0200 Subject: [PATCH] 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 --- .../StellaOps.Notify.WebService/Program.cs | 170 ++++++++++++++++-- .../ChannelHealthContracts.cs | 2 + .../NotifyTestServiceOverrides.cs | 2 + .../W1/NotifyWebServiceContractTests.cs | 89 ++++++++- 4 files changed, 246 insertions(+), 17 deletions(-) diff --git a/src/Notify/StellaOps.Notify.WebService/Program.cs b/src/Notify/StellaOps.Notify.WebService/Program.cs index a3b9d3440..a2c4ca3ec 100644 --- a/src/Notify/StellaOps.Notify.WebService/Program.cs +++ b/src/Notify/StellaOps.Notify.WebService/Program.cs @@ -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(entity.Config); - var metadataModel = TryDeserialize(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(string? json) } } +static NotifyChannelConfig ToNotifyChannelConfig(ChannelEntity entity) +{ + if (string.IsNullOrWhiteSpace(entity.Config)) + { + return NotifyChannelConfig.Create("inline-default"); + } + + var config = TryDeserialize(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> ReadLegacyChannelProperties(JsonElement root) +{ + var properties = new Dictionary(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) diff --git a/src/Notify/__Libraries/StellaOps.Notify.Engine/ChannelHealthContracts.cs b/src/Notify/__Libraries/StellaOps.Notify.Engine/ChannelHealthContracts.cs index e613a2c1f..0e6bd2ae8 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Engine/ChannelHealthContracts.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Engine/ChannelHealthContracts.cs @@ -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( /// /// Supported channel health states. /// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum ChannelHealthStatus { Healthy, diff --git a/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/NotifyTestServiceOverrides.cs b/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/NotifyTestServiceOverrides.cs index 0ae4840ab..ad27a9449 100644 --- a/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/NotifyTestServiceOverrides.cs +++ b/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/NotifyTestServiceOverrides.cs @@ -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(); + services.RemoveAll(); // Replace repository registrations with in-memory implementations. // Using singletons so data persists across scoped requests within a single test. diff --git a/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/W1/NotifyWebServiceContractTests.cs b/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/W1/NotifyWebServiceContractTests.cs index c3d2107d3..d830830bb 100644 --- a/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/W1/NotifyWebServiceContractTests.cs +++ b/src/Notify/__Tests/StellaOps.Notify.WebService.Tests/W1/NotifyWebServiceContractTests.cs @@ -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 node?.AsObject()) + .FirstOrDefault(node => node?["name"]?.GetValue() == "legacy-slack"); + + legacyChannel.Should().NotBeNull(); + legacyChannel!["config"]?["secretRef"]?.GetValue().Should().Be("legacy://notify/channels/e00000010000000000000000000000aa"); + legacyChannel["config"]?["target"]?.GetValue().Should().Be("#security-alerts"); + legacyChannel["config"]?["endpoint"]?.GetValue().Should().Be("https://hooks.slack.example.com/services/demo"); + legacyChannel["config"]?["properties"]?["username"]?.GetValue().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().Should().Be(channelId.ToString()); + json["status"]?.GetValue().Should().NotBeNullOrWhiteSpace(); + json["metadata"]?["target"]?.GetValue().Should().Be("https://notify.example.test/hooks/demo"); + } + [Fact] public async Task CreateChannel_ValidPayload_Returns201() { @@ -434,6 +514,13 @@ public class NotifyWebServiceContractTests : IClassFixture(); + await repository.CreateAsync(entity, CancellationToken.None); + } + private static string CreateToken(params string[] scopes) { return NotifyTestServiceOverrides.CreateTestToken( @@ -518,5 +605,3 @@ public class NotifyWebServiceContractTests : IClassFixture