From ea5ad21e47723e36bba02dd239a1e695d4739b24 Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Mon, 22 Jun 2026 13:24:46 -0600 Subject: [PATCH 1/3] feat(alerting): add per-alert snooze and silent auto-resolve Per-alert snooze (4h / 24h / 7d presets) plus silent auto-resolve on metric recovery. Snooze data lives on TriggeredAlert and composes with the existing per-policy SuppressionMinutes. Schema: adds SnoozedUntil, SnoozedBy, BelowThresholdStreakCount, and LastEvaluatedAt columns to TriggeredAlerts via idempotent IF COL_LENGTH ALTER blocks, mirroring the existing SourceFailureDetails pattern. API: POST /api/triggered-alerts/{id}/snooze accepts an absolute timestamp or a duration in hours; POST .../unsnooze clears the state. Snoozing a terminal-state alert returns 400. Evaluator: after the existing dispatch loop, scans non-terminal alerts, increments or resets a per-alert streak counter on each evaluation, and transitions to Status="auto_resolved" after AutoResolveDebounceCycles (default 2) consecutive below-threshold observations. Resolution is silent - no notification dispatch, no NotificationLog write. Manual resolve remains the user-driven terminal state; the first transition wins. Config: new Alerting section in appsettings.json; bound via the existing IOptions pattern. --- .../Data/AlertingSchema.cs | 12 ++++++ .../Models/AlertingOptions.cs | 16 +++++++ .../Models/TriggeredAlert.cs | 13 ++++++ src/M365SecurityDashboard.Api/Program.cs | 33 +++++++++++++++ .../Services/AlertEvaluator.cs | 42 ++++++++++++++++++- .../appsettings.json | 3 ++ 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/M365SecurityDashboard.Api/Models/AlertingOptions.cs diff --git a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs index 72a0cef..19994ce 100644 --- a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs +++ b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs @@ -76,6 +76,18 @@ public static class AlertingSchema IF COL_LENGTH(N'[CollectionRuns]', 'SourceFailureDetails') IS NULL ALTER TABLE [CollectionRuns] ADD [SourceFailureDetails] nvarchar(max) NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedUntil') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [SnoozedUntil] datetimeoffset NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedBy') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [SnoozedBy] nvarchar(120) NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'BelowThresholdStreakCount') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [BelowThresholdStreakCount] int NOT NULL CONSTRAINT [DF_TriggeredAlerts_Streak] DEFAULT 0; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'LastEvaluatedAt') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [LastEvaluatedAt] datetimeoffset NULL; """; private static readonly (string Name, string Category, string Metric, int Threshold, string Severity, string Condition)[] Defaults = diff --git a/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs new file mode 100644 index 0000000..5890f37 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs @@ -0,0 +1,16 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Configuration for the server-side alerting engine. Bound from the +/// "Alerting" section of appsettings.json. +/// +public sealed class AlertingOptions +{ + /// + /// Number of consecutive evaluation cycles the underlying metric must be + /// below a triggered alert's threshold before the alert auto-resolves. + /// With the default collection interval of 15 minutes and a value of 2, + /// an alert auto-resolves ~30 minutes after the metric recovers. + /// + public int AutoResolveDebounceCycles { get; set; } = 2; +} diff --git a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs index 9ce7a47..a23425c 100644 --- a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs +++ b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs @@ -39,4 +39,17 @@ public sealed class TriggeredAlert /// Whether outbound notifications were dispatched for this alert. public bool Notified { get; set; } + + /// If set, the alert is silenced until this timestamp. Status remains "new" or "acknowledged". + public DateTimeOffset? SnoozedUntil { get; set; } + + /// Identity of the actor who snoozed this alert. Placeholder string ("dashboard") until auth lands. + [MaxLength(120)] + public string? SnoozedBy { get; set; } + + /// Number of consecutive evaluation cycles the underlying metric has been below this alert's threshold. Reset on any above-threshold observation. + public int BelowThresholdStreakCount { get; set; } + + /// When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce). + public DateTimeOffset? LastEvaluatedAt { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Program.cs b/src/M365SecurityDashboard.Api/Program.cs index 73e8225..dec3942 100644 --- a/src/M365SecurityDashboard.Api/Program.cs +++ b/src/M365SecurityDashboard.Api/Program.cs @@ -10,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(); builder.Services.Configure(builder.Configuration.GetSection("Graph")); +builder.Services.Configure(builder.Configuration.GetSection("Alerting")); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddHttpClient(); @@ -1077,6 +1078,35 @@ app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, G return Results.Ok(t); }); +// Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }. +// Until wins if both are supplied; durationHours defaults to 24 if neither is supplied. +app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async ( + AppDbContext db, Guid id, SnoozeRequest input, CancellationToken ct) => +{ + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + if (t.Status is "resolved" or "auto_resolved") + return Results.BadRequest(new { error = "Cannot snooze a terminal alert." }); + + var until = input.Until + ?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(h) : DateTimeOffset.UtcNow.AddHours(24)); + t.SnoozedUntil = until; + t.SnoozedBy = "dashboard"; + await db.SaveChangesAsync(ct); + return Results.Ok(t); +}); + +app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async ( + AppDbContext db, Guid id, CancellationToken ct) => +{ + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.SnoozedUntil = null; + t.SnoozedBy = null; + await db.SaveChangesAsync(ct); + return Results.Ok(t); +}); + // Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check) app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) => { @@ -1150,3 +1180,6 @@ app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct app.MapFallbackToFile("index.html"); app.Run(); + +/// Body shape for POST /api/triggered-alerts/{id}/snooze. +public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours); diff --git a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs index 7c62dcc..c8c951e 100644 --- a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs +++ b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs @@ -1,6 +1,7 @@ using M365SecurityDashboard.Api.Data; using M365SecurityDashboard.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace M365SecurityDashboard.Api.Services; @@ -8,10 +9,12 @@ namespace M365SecurityDashboard.Api.Services; /// Evaluates all enabled rows against the latest /// collected data and persists new rows. Runs /// server-side after every collection cycle so alerts fire without a browser. +/// Also auto-resolves stale alerts whose underlying metric has recovered. /// public sealed class AlertEvaluator( AppDbContext db, NotificationSender sender, + IOptions options, ILogger logger) { public async Task EvaluateAsync(CancellationToken ct) @@ -26,6 +29,10 @@ public sealed class AlertEvaluator( var now = DateTimeOffset.UtcNow; var fired = 0; + // Map PolicyId -> Metric key so the auto-resolve loop below can look up + // each open alert's current metric without re-querying the policy table. + var policyMetricById = policies.ToDictionary(p => p.Id, p => p.Metric); + foreach (var policy in policies) { var value = metrics.GetValueOrDefault(policy.Metric, 0); @@ -67,10 +74,41 @@ public sealed class AlertEvaluator( } } - if (fired > 0) + // Auto-resolve: scan non-terminal alerts and update streak counters. + // Resolves silently — no notification dispatch, no NotificationLog write. + var streakTarget = Math.Max(1, options.Value.AutoResolveDebounceCycles); + var openAlerts = await db.TriggeredAlerts + .Where(t => t.Status != "resolved" && t.Status != "auto_resolved") + .ToListAsync(ct); + var autoResolved = 0; + foreach (var alert in openAlerts) + { + if (!policyMetricById.TryGetValue(alert.PolicyId, out var metricKey)) continue; + var current = metrics.GetValueOrDefault(metricKey, 0); + + if (current < alert.Threshold) + { + alert.BelowThresholdStreakCount++; + if (alert.BelowThresholdStreakCount >= streakTarget) + { + alert.Status = "auto_resolved"; + autoResolved++; + } + } + else if (alert.BelowThresholdStreakCount != 0) + { + alert.BelowThresholdStreakCount = 0; + } + alert.LastEvaluatedAt = now; + } + + if (fired > 0 || autoResolved > 0) { await db.SaveChangesAsync(ct); - logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); + if (fired > 0) + logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); + if (autoResolved > 0) + logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved); } return fired; } diff --git a/src/M365SecurityDashboard.Api/appsettings.json b/src/M365SecurityDashboard.Api/appsettings.json index 86758ae..24cf5eb 100644 --- a/src/M365SecurityDashboard.Api/appsettings.json +++ b/src/M365SecurityDashboard.Api/appsettings.json @@ -13,5 +13,8 @@ "ExchangeQuarantinePath": "/beta/security/alerts_v2?$top=50&$filter=serviceSource%20eq%20'microsoftDefenderForOffice365'%20and%20category%20eq%20'EmailMalware'", "MailFlowIssuesPath": "/v1.0/admin/serviceAnnouncement/issues?$top=50&$filter=service%20eq%20'Exchange%20Online'%20and%20isResolved%20eq%20false" }, + "Alerting": { + "AutoResolveDebounceCycles": 2 + }, "AllowedHosts": "*" } From ac685b471244225a168c4e52f8990564b962293c Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Mon, 22 Jun 2026 13:29:42 -0600 Subject: [PATCH 2/3] test(alerting): add xUnit project for auto-resolve path Adds M365SecurityDashboard.Api.Tests with seven focused tests for the new auto-resolve loop in AlertEvaluator: - Streak increments on a below-threshold observation - Auto-resolve after AutoResolveDebounceCycles consecutive observations - Streak resets to zero on any above-threshold observation - Debounce-of-one auto-resolves on the first observation - Terminal-state alerts (resolved, auto_resolved) are skipped - No notification dispatch on auto-resolve - AcknowledgedAt and AcknowledgedBy survive an auto-resolve transition Uses Microsoft.EntityFrameworkCore.InMemory for the test DbContext. NotificationSender is constructed but never invoked by the loop under test, so no HTTP/DPAPI dependencies are exercised. --- M365SecurityAlertDashboard.sln | 13 +- .../AlertEvaluatorAutoResolveTests.cs | 349 ++++++++++++++++++ .../M365SecurityDashboard.Api.Tests.csproj | 28 ++ .../TestAppDbContextFactory.cs | 20 + 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs create mode 100644 src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj create mode 100644 src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs diff --git a/M365SecurityAlertDashboard.sln b/M365SecurityAlertDashboard.sln index 4085e4c..6dec17b 100644 --- a/M365SecurityAlertDashboard.sln +++ b/M365SecurityAlertDashboard.sln @@ -1,9 +1,13 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api", "src\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj", "{A0AD3D97-983E-4665-B8A0-BB72D07686B6}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8CEF94CD-3FDE-42A2-B93C-4D9552702532}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api.Tests", "src\M365SecurityDashboard.Api.Tests\M365SecurityDashboard.Api.Tests.csproj", "{B8504228-F65D-4D8B-B972-B6471E615FB9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -14,5 +18,12 @@ Global {A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.Build.0 = Release|Any CPU + {B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B8504228-F65D-4D8B-B972-B6471E615FB9} = {8CEF94CD-3FDE-42A2-B93C-4D9552702532} EndGlobalSection EndGlobal diff --git a/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs new file mode 100644 index 0000000..4efaed3 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs @@ -0,0 +1,349 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Focused tests for the auto-resolve path in . +/// Covers streak increment / reset, transition to auto_resolved, +/// terminal-state skip, debounce-of-one, no notification dispatch on +/// resolution, and preservation of user-set fields. +/// +public class AlertEvaluatorAutoResolveTests +{ + private const string RiskyUsersMetric = "riskyUsersCount"; + private const int Threshold = 3; + + private static AlertEvaluator BuildEvaluator(AppDbContext db, int autoResolveDebounceCycles = 2) + { + var options = Microsoft.Extensions.Options.Options.Create(new AlertingOptions + { + AutoResolveDebounceCycles = autoResolveDebounceCycles, + }); + + // The auto-resolve loop never calls DispatchAsync; we still need a + // real NotificationSender because AlertEvaluator takes it in its + // primary constructor. All channels are disabled below, so any + // accidental dispatch attempt writes no NotificationLog rows. + var sender = new NotificationSender( + new NullHttpClientFactory(), + new SecretProtector(NullLogger.Instance), + NullLogger.Instance); + + return new AlertEvaluator( + db, + sender, + options, + NullLogger.Instance); + } + + private static AlertPolicy RiskyUsersPolicy(int threshold = Threshold) => new() + { + Id = Guid.NewGuid(), + Name = "Risky Users", + Enabled = true, + Category = "identity", + Metric = RiskyUsersMetric, + Threshold = threshold, + Severity = "high", + Condition = "Risky users ≥ 1", + SuppressionMinutes = 60, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }; + + private static void SeedOpenRiskyUsers(AppDbContext db, int count) + { + for (var i = 0; i < count; i++) + { + db.SecurityAlerts.Add(new SecurityAlert + { + AlertType = "RiskyUser", + Severity = AlertSeverity.High, + Service = M365ServiceArea.EntraId, + Title = $"risky-{i}", + DetectedAt = DateTimeOffset.UtcNow.AddHours(-i), + IsResolved = false, + }); + } + db.SaveChanges(); + } + + [Fact] + public async Task Streak_IncrementsOnBelowThresholdObservation() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "new", + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold + + var evaluator = BuildEvaluator(db); + await evaluator.EvaluateAsync(CancellationToken.None); + + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(1, alert.BelowThresholdStreakCount); + Assert.Equal("new", alert.Status); + Assert.NotNull(alert.LastEvaluatedAt); + } + + [Fact] + public async Task AutoResolve_AfterNConsecutiveBelow() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "new", + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold + + var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 2); + + // First evaluation: streak goes from 0 to 1, status stays "new". + await evaluator.EvaluateAsync(CancellationToken.None); + var afterFirst = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(1, afterFirst.BelowThresholdStreakCount); + Assert.Equal("new", afterFirst.Status); + + // Second consecutive below-threshold evaluation: streak hits 2, auto-resolves. + await evaluator.EvaluateAsync(CancellationToken.None); + var afterSecond = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal("auto_resolved", afterSecond.Status); + Assert.Equal(2, afterSecond.BelowThresholdStreakCount); + + // Resolution is silent: no NotificationLog rows for this alert. + Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == afterSecond.Id).ToListAsync()); + } + + [Fact] + public async Task Streak_ResetsOnAboveThreshold() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "new", + BelowThresholdStreakCount = 1, // already had one below observation + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 5); // metric = 5, above threshold + + var evaluator = BuildEvaluator(db); + await evaluator.EvaluateAsync(CancellationToken.None); + + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(0, alert.BelowThresholdStreakCount); + Assert.Equal("new", alert.Status); + } + + [Fact] + public async Task AutoResolve_DebounceOneWithCyclesEqualsOne() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "new", + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); + + var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1); + await evaluator.EvaluateAsync(CancellationToken.None); + + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal("auto_resolved", alert.Status); + } + + [Fact] + public async Task AutoResolve_SkipsTerminalStates() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + + var resolvedId = Guid.NewGuid(); + var autoResolvedId = Guid.NewGuid(); + db.TriggeredAlerts.AddRange( + new TriggeredAlert + { + Id = resolvedId, + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "resolved", + BelowThresholdStreakCount = 1, + }, + new TriggeredAlert + { + Id = autoResolvedId, + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "auto_resolved", + BelowThresholdStreakCount = 2, + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); + + var evaluator = BuildEvaluator(db); + await evaluator.EvaluateAsync(CancellationToken.None); + + // The auto-resolve loop filters out terminal-state alerts, so it + // does not touch their streak counter, their status, or stamp a + // LastEvaluatedAt on them. A terminal state cannot re-open. + var resolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == resolvedId); + Assert.Equal("resolved", resolvedRow.Status); + Assert.Equal(1, resolvedRow.BelowThresholdStreakCount); + Assert.Null(resolvedRow.LastEvaluatedAt); + + var autoResolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == autoResolvedId); + Assert.Equal("auto_resolved", autoResolvedRow.Status); + Assert.Equal(2, autoResolvedRow.BelowThresholdStreakCount); + Assert.Null(autoResolvedRow.LastEvaluatedAt); + } + + [Fact] + public async Task AutoResolve_DoesNotDispatchNotification() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + + // Pre-seed a notification settings row with a Teams URL that would + // fail to deliver. If the auto-resolve path accidentally calls + // DispatchAsync, the failure would be logged here. + db.NotificationSettings.Add(new NotificationSettings + { + Id = 1, + TeamsEnabled = true, + TeamsWebhookUrl = "http://127.0.0.1:1/this-port-is-closed", + EmailEnabled = false, + WebhookEnabled = false, + SmtpPort = 587, + MinSeverity = "low", + }); + + var alertId = Guid.NewGuid(); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = alertId, + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "new", + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); + + var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1); + await evaluator.EvaluateAsync(CancellationToken.None); + + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal("auto_resolved", alert.Status); + // No log entry for this alert: auto-resolve is silent. + Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == alert.Id).ToListAsync()); + } + + [Fact] + public async Task AutoResolve_PreservesAcknowledgedAt() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + var ackAt = DateTimeOffset.UtcNow.AddMinutes(-15); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + PolicyName = policy.Name, + Severity = policy.Severity, + Category = policy.Category, + Condition = policy.Condition, + MetricValue = 5, + Threshold = policy.Threshold, + TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30), + Status = "acknowledged", + AcknowledgedAt = ackAt, + AcknowledgedBy = "dashboard", + }); + await db.SaveChangesAsync(); + SeedOpenRiskyUsers(db, count: 0); + + var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1); + await evaluator.EvaluateAsync(CancellationToken.None); + + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal("auto_resolved", alert.Status); + Assert.Equal(ackAt, alert.AcknowledgedAt); + Assert.Equal("dashboard", alert.AcknowledgedBy); + } + + /// No-op for tests that never make HTTP calls. + private sealed class NullHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj new file mode 100644 index 0000000..8a32bde --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs b/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs new file mode 100644 index 0000000..8e0a766 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs @@ -0,0 +1,20 @@ +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Test helper that returns a fresh in-memory +/// for each call. Each test gets its own database (named by a unique Guid) +/// so state cannot leak between tests. +/// +internal static class TestAppDbContextFactory +{ + public static AppDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new AppDbContext(options); + } +} From 3c52f8d2ae210c12a1f1b4d9b28d8b52b72e9089 Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Mon, 22 Jun 2026 13:36:37 -0600 Subject: [PATCH 3/3] feat(ui): snooze controls in alert center Wires the Alert Center to the new per-alert snooze endpoints and renders the new server-side fields (snoozedUntil, lastEvaluatedAt). - TriggeredAlert interface extended with status union members ('snoozed' | 'auto_resolved') and the four new fields. - acApi gains snooze(id, durationHours) and unsnooze(id). - statusTone maps the new statuses to neutral (snoozed) and info (auto_resolved) tones so they read distinctly from new / acknowledged. - Status filter dropdown gets two new options so users can isolate snoozed or auto-resolved rows. - Action cell gains a snooze ({ Policy:a.policyName, Severity:a.severity, Category:a.category, Condition:a.condition, MetricValue:a.metricValue, Threshold:a.threshold, Triggered:a.triggeredAt, Status:a.status }))} filename="triggered-alerts.csv"/> @@ -4007,10 +4036,29 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: { {a.metricValue} {a.threshold} {relTime(a.triggeredAt)} - - e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center" }}> + + {a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && ( +
snoozed until {relTime(a.snoozedUntil)}
+ )} + + e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center", flexWrap:"wrap" }}> {a.status === "new" && } - {a.status !== "resolved" && } + {a.status !== "resolved" && a.status !== "auto_resolved" && ( + + )} + {a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && ( + + )} + {a.status !== "resolved" && a.status !== "auto_resolved" && } ))}