mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
7b975d46b6
New anonymous POST ops/alert-webhook receives Common Alert Schema notifications from an Action Group webhook receiver and re-emits them as ops events (category Platform, type AzureMonitorAlert), so Azure Monitor alerts flow through the existing OpsEventService -> OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal ops-events list) like every internally raised operational event. The delivery path is independent of the portal: a SWA outage cannot suppress the notification; the action group's e-mail receiver stays as the backend-independent fallback. Security: action-group webhooks cannot send custom headers, so the shared secret travels as ?secret= and is compared in constant time (FixedTimeEquals) against the OpsAlertWebhookSecret app setting - fail-closed while unset. 64 KB payload cap, defensive essentials-only parsing (only alertRule is mandatory), severity mapped Sev0->Critical, Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling back to Info so schema drift can never escalate. Dual-registered per convention: EndpointAccessPolicyCatalog entry (PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule wiring) and Platform category in OpsEventsSection (badge + filter). Backend suite 3511 green (incl. new helper tests + catalog completeness), web tsc + vitest 651 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
7.1 KiB
C#
173 lines
7.1 KiB
C#
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
|
|
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
|
|
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
|
|
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
|
|
/// parsing is defensive (only the alert rule name is mandatory).
|
|
/// </summary>
|
|
public class AzureMonitorAlertWebhookFunctionTests
|
|
{
|
|
// ── SecretMatches ──────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void SecretMatches_accepts_equal_secrets()
|
|
{
|
|
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("wrong", "right")]
|
|
[InlineData("right-but-longer", "right")]
|
|
[InlineData("", "configured")]
|
|
[InlineData(null, "configured")]
|
|
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
|
|
{
|
|
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
public void SecretMatches_fails_closed_when_not_configured(string? configured)
|
|
{
|
|
// While the app setting is unset, NOTHING matches — not even an empty provided value.
|
|
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
|
|
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
|
|
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
|
|
}
|
|
|
|
// ── MapSeverity ────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("Sev0", OpsEventSeverity.Critical)]
|
|
[InlineData("Sev1", OpsEventSeverity.Error)]
|
|
[InlineData("Sev2", OpsEventSeverity.Warning)]
|
|
[InlineData("Sev3", OpsEventSeverity.Info)]
|
|
[InlineData("Sev4", OpsEventSeverity.Info)]
|
|
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
|
|
{
|
|
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("Sev5")]
|
|
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
|
|
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
|
|
{
|
|
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
|
}
|
|
|
|
// ── TryParse ───────────────────────────────────────────────────────────
|
|
|
|
private const string FullPayload = """
|
|
{
|
|
"schemaId": "azureMonitorCommonAlertSchema",
|
|
"data": {
|
|
"essentials": {
|
|
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
|
|
"alertRule": "Portal RSC prefetch tail",
|
|
"severity": "Sev2",
|
|
"signalType": "Log",
|
|
"monitorCondition": "Fired",
|
|
"monitoringService": "Log Alerts V2",
|
|
"alertTargetIDs": [
|
|
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
|
|
],
|
|
"description": "RSC prefetches exceeding 10s",
|
|
"firedDateTime": "2026-07-27T15:30:00.000Z"
|
|
},
|
|
"alertContext": {
|
|
"condition": {
|
|
"windowSize": "PT1H",
|
|
"allOf": [
|
|
{
|
|
"searchQuery": "dependencies | where name contains '_rsc='",
|
|
"metricValue": 18.0,
|
|
"operator": "GreaterThan",
|
|
"threshold": "0"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public void TryParse_extracts_essentials_and_metric_value()
|
|
{
|
|
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
|
|
|
|
Assert.NotNull(alert);
|
|
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
|
|
Assert.Equal("Sev2", alert.Severity);
|
|
Assert.Equal("Fired", alert.MonitorCondition);
|
|
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
|
|
Assert.Equal("Log Alerts V2", alert.MonitoringService);
|
|
// No targetResourceName in the payload → resource name segment of the first target ID.
|
|
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
|
|
Assert.Equal(18.0, alert.MetricValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParse_prefers_explicit_target_resource_name()
|
|
{
|
|
const string payload = """
|
|
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
|
|
"alertTargetIDs":["/sub/x/other-name"]}}}
|
|
""";
|
|
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
|
Assert.Equal("explicit-name", alert!.TargetResource);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParse_minimal_payload_needs_only_the_alert_rule()
|
|
{
|
|
var alert = AzureMonitorAlertWebhookFunction.TryParse(
|
|
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
|
|
|
|
Assert.NotNull(alert);
|
|
Assert.Equal("minimal", alert!.AlertRule);
|
|
Assert.Null(alert.Severity);
|
|
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
|
|
Assert.Null(alert.Description);
|
|
Assert.Null(alert.MetricValue);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("not json at all")]
|
|
[InlineData("{}")] // no data
|
|
[InlineData("""{"data":{}}""")] // no essentials
|
|
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
|
|
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
|
|
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
|
|
public void TryParse_rejects_unusable_payloads(string? body)
|
|
{
|
|
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParse_tolerates_malformed_alert_context()
|
|
{
|
|
// alertContext with unexpected shapes must not break essentials extraction.
|
|
const string payload = """
|
|
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
|
|
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
|
|
""";
|
|
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
|
Assert.NotNull(alert);
|
|
Assert.Equal("Sev0", alert!.Severity);
|
|
Assert.Null(alert.MetricValue);
|
|
}
|
|
}
|