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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user