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)
|
||||
|
||||
Reference in New Issue
Block a user