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)