mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65842b87bf | |||
| b323c52364 | |||
| 321a6efc81 | |||
| 5ecb2a998b | |||
| bb6df93b51 | |||
| 096a049deb | |||
| b9caf524c3 | |||
| 197de1d718 | |||
| 18d4f08f2b | |||
| ddf450d87d | |||
| e3b2113365 | |||
| 6a85f45604 | |||
| a2d1aedcad | |||
| e955faa97e | |||
| 2b755bf99a | |||
| ce1162a055 | |||
| 447786b447 | |||
| 2b4dbe087a | |||
| 6804bcfd4e | |||
| d2221eb0b9 | |||
| 9ac6de7c7d | |||
| f5613be495 | |||
| 819b6c8e4f | |||
| 9c1fb07f33 | |||
| fa31872903 | |||
| b08b79eafe | |||
| e43d916c24 | |||
| 14b185db35 | |||
| baa51102fd | |||
| 5577f5e7e5 | |||
| 61892592e4 | |||
| 1e2ca46946 |
+1
-1
Submodule .claude/commands updated: 8dcfe1e23e...cb96a972a0
+1
-1
Submodule infra updated: 55a8eaf929...742249e7e3
+1
-1
Submodule internal updated: 31957733a2...aa57d83ea0
@@ -5,6 +5,7 @@
|
||||
"description": "Detects when a certificate with the configured subject is missing from the local machine personal store (LocalMachine\\My). Requires the gather rule GATHER-ID-002 (machine cert store inventory) to be enabled — the check runs against its gather_machine_cert_store output. A missing certificate may indicate that MDM enrollment certificate provisioning or SCEP certificate deployment failed.",
|
||||
"severity": "warning",
|
||||
"category": "identity",
|
||||
"author": "Community",
|
||||
"isCommunity": true,
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
|
||||
+1
@@ -2806,6 +2806,7 @@
|
||||
"description": "Detects when a certificate with the configured subject is missing from the local machine personal store (LocalMachine\\My). Requires the gather rule GATHER-ID-002 (machine cert store inventory) to be enabled — the check runs against its gather_machine_cert_store output. A missing certificate may indicate that MDM enrollment certificate provisioning or SCEP certificate deployment failed.",
|
||||
"severity": "warning",
|
||||
"category": "identity",
|
||||
"author": "Community",
|
||||
"isCommunity": true,
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
|
||||
+16
@@ -646,6 +646,22 @@
|
||||
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 2).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-ARG",
|
||||
"category": "always",
|
||||
"pattern": "^Adding argument (?<kind>[A-Za-z0-9]+) with value ",
|
||||
"action": "agentInvocationArgument",
|
||||
"description": "The first argument line of every AgentExecutor.exe invocation (powershell, remediationScript, detectionScript, requirementScript, ...). Opens the invocation that owns the policyId-less exit/output lines following it in AgentExecutor.log; PS-AGENT-SCRIPT-START upgrades a powershell invocation to its platform script. Also keeps the tracker's overwrite check active while the invocation runs.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-COMPLETED",
|
||||
"category": "always",
|
||||
"pattern": "^Agent executor completed\\.",
|
||||
"action": "agentExecutorCompleted",
|
||||
"description": "Last line of an AgentExecutor.exe invocation. Closes the invocation's ownership of the exit/output lines in AgentExecutor.log and triggers the tracker's overwrite check: the executor wrote its end block at its own stream position, which may lie behind the tracker's bookmark when another process appended meanwhile.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-ERROR",
|
||||
"category": "always",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "PS-AGENT-ARG",
|
||||
"category": "always",
|
||||
"pattern": "^Adding argument (?<kind>[A-Za-z0-9]+) with value ",
|
||||
"action": "agentInvocationArgument",
|
||||
"description": "The first argument line of every AgentExecutor.exe invocation (powershell, remediationScript, detectionScript, requirementScript, ...). Opens the invocation that owns the policyId-less exit/output lines following it in AgentExecutor.log; PS-AGENT-SCRIPT-START upgrades a powershell invocation to its platform script. Also keeps the tracker's overwrite check active while the invocation runs.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "PS-AGENT-COMPLETED",
|
||||
"category": "always",
|
||||
"pattern": "^Agent executor completed\\.",
|
||||
"action": "agentExecutorCompleted",
|
||||
"description": "Last line of an AgentExecutor.exe invocation. Closes the invocation's ownership of the exit/output lines in AgentExecutor.log and triggers the tracker's overwrite check: the executor wrote its end block at its own stream position, which may lie behind the tracker's bookmark when another process appended meanwhile.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -71,6 +71,8 @@
|
||||
"scriptOutput",
|
||||
"scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
"agentInvocationArgument",
|
||||
"agentExecutorCompleted",
|
||||
"healthScriptResult",
|
||||
"healthScriptDetectionResult",
|
||||
"captureExitCode",
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 46749560: IME's trace listener writes every log through one FileMode.Append
|
||||
/// stream per process, so a process that resumes logging after another one appended
|
||||
/// overwrites those bytes at its own stale position. The tracker had read the first version
|
||||
/// and never saw the second — 6 of 16 script results and 4 of 16 executor end blocks were
|
||||
/// lost while the final files looked intact. These tests drive that exact write pattern
|
||||
/// (positional overwrite behind the bookmark) against the two multi-writer logs and pin the
|
||||
/// contract: unchanged bytes never rewind, changed bytes are processed exactly once, and the
|
||||
/// executor lines a rewind uncovers belong to the invocation that owns their file position.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<ImeLogPattern> MarkerPatterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-MARK", Category = "always", Enabled = true,
|
||||
Pattern = @"^marker (?<n>\d+)", Action = "noop",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static readonly string[] ScriptPatternIds =
|
||||
{
|
||||
"PS-AGENT-INVOCATION", "PS-AGENT-ARG", "PS-AGENT-SCRIPT-START", "PS-AGENT-EXITCODE",
|
||||
"PS-AGENT-OUTPUT", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
|
||||
};
|
||||
|
||||
/// <summary>The shipped pattern JSON — the same source combine.js embeds — so the contract is the real one.</summary>
|
||||
private static List<ImeLogPattern> ScriptPatterns()
|
||||
{
|
||||
DirectoryInfo? dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? patternDir = null;
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) { patternDir = candidate; break; }
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(patternDir);
|
||||
|
||||
var byId = new Dictionary<string, ImeLogPattern>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in Directory.GetFiles(patternDir, "*.json"))
|
||||
{
|
||||
var pattern = JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(file));
|
||||
if (pattern?.PatternId != null) byId[pattern.PatternId] = pattern;
|
||||
}
|
||||
return ScriptPatternIds.Select(id =>
|
||||
{
|
||||
Assert.True(byId.TryGetValue(id, out var p), $"Shipped IME pattern '{id}' not found under {patternDir}.");
|
||||
return p!;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string Entry(string message, string thread = "1")
|
||||
=> $"<![LOG[{message}]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"X\" context=\"\" type=\"1\" thread=\"{thread}\" file=\"\">\r\n";
|
||||
|
||||
private const string PlatformId = "d39eeef3-e88e-41a0-82d7-5e28ee7b5acf";
|
||||
private const string PlatformStartLine =
|
||||
@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\73d664e4-0886-4a73-b745-c694da45ddb4_d39eeef3-e88e-41a0-82d7-5e28ee7b5acf.ps1 to the named argument list.";
|
||||
private const string DetectionStartLine =
|
||||
@"Adding argument detectionScript with value C:\Windows\IMECache\DetectionScripts\App_cc46aa62-e93a-41a1-bdd0-44f57e92a66d.ps1 to the named argument list.";
|
||||
private const string GeneratedLine =
|
||||
@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\73d664e4-0886-4a73-b745-c694da45ddb4_d39eeef3-e88e-41a0-82d7-5e28ee7b5acf.ps1 is generated.";
|
||||
private const string ResultLine =
|
||||
"[PowerShell] User Id = 73d664e4-0886-4a73-b745-c694da45ddb4, Policy id = " + PlatformId + ", policy result = Success";
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly string _matchLog;
|
||||
private readonly List<ImeLogPattern> _patterns;
|
||||
public ImeLogTracker Tracker { get; private set; }
|
||||
public DateTime Now { get; set; } = T0;
|
||||
public string LogName { get; }
|
||||
public string LogPath => Path.Combine(_tmp.Path, LogName);
|
||||
public string StateDir { get; }
|
||||
public List<ScriptExecutionState> Completed { get; } = new List<ScriptExecutionState>();
|
||||
|
||||
public Harness(string logName, List<ImeLogPattern> patterns)
|
||||
{
|
||||
LogName = logName;
|
||||
_patterns = patterns;
|
||||
var logDir = Path.Combine(_tmp.Path, "agent");
|
||||
Directory.CreateDirectory(logDir);
|
||||
_matchLog = Path.Combine(logDir, "ime-pattern-matches.log");
|
||||
StateDir = Path.Combine(_tmp.Path, "state");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
private ImeLogTracker Build()
|
||||
{
|
||||
var t = new ImeLogTracker(_tmp.Path, _patterns, new AgentLogger(Path.Combine(_tmp.Path, "agent"), AgentLogLevel.Debug),
|
||||
matchLogPath: _matchLog, stateDirectory: StateDir);
|
||||
t.UtcNowProvider = () => Now;
|
||||
t.OnScriptCompleted = s => Completed.Add(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Agent restart: dispose, rebuild over the same state directory, restore the bookmark.</summary>
|
||||
public void Restart()
|
||||
{
|
||||
Tracker.SaveStateForTest();
|
||||
Tracker.Dispose();
|
||||
Tracker = Build();
|
||||
Tracker.LoadStateForTest();
|
||||
}
|
||||
|
||||
public void Append(string text) => File.AppendAllText(LogPath, text, new UTF8Encoding(false));
|
||||
|
||||
/// <summary>What IME's stale writer does: write at its own position, over whatever is there.</summary>
|
||||
public void Overwrite(long offset, string text)
|
||||
{
|
||||
using (var fs = new FileStream(LogPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
fs.Seek(offset, SeekOrigin.Begin);
|
||||
var bytes = new UTF8Encoding(false).GetBytes(text);
|
||||
fs.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public long Length => new FileInfo(LogPath).Length;
|
||||
|
||||
public Task Pass(CancellationToken token = default) => Tracker.CheckLogFilesAsync(token);
|
||||
|
||||
/// <summary>Marker numbers matched so far, in match order.</summary>
|
||||
public List<int> Matched()
|
||||
{
|
||||
if (!File.Exists(_matchLog)) return new List<int>();
|
||||
return File.ReadAllLines(_matchLog)
|
||||
.Select(l => Regex.Match(l, @"\[T-MARK\] <!\[LOG\[marker (\d+)"))
|
||||
.Where(m => m.Success).Select(m => int.Parse(m.Groups[1].Value)).ToList();
|
||||
}
|
||||
|
||||
public ImeTrackerHealth Health => Tracker.GetHealthSnapshot();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Marker-level contract on AgentExecutor.log
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_bytes_never_rewind_however_often_they_are_verified()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + "<![LOG[marker 2\nsecond line\n]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n" + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(5, h.Health.VerifyPasses);
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_multi_writer_files_keep_no_ledger_and_are_never_verified()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
Assert.Equal(0, h.Tracker.LedgerEntryCountForTest("AppWorkload.log"));
|
||||
Assert.Equal(0, h.Health.VerifyPasses);
|
||||
Assert.Equal(0, h.Health.VerifiedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_written_over_read_bytes_is_processed_once_and_the_surviving_tail_is_not_repeated()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
var one = Entry("marker 1");
|
||||
h.Append(one + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
|
||||
// A stale writer lays two entries over the first two (same length: marker 3 survives
|
||||
// intact at its old offset). No growth, no fragment — only the end signal finds it.
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8"));
|
||||
Assert.Equal(3 * one.Length, h.Length);
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(3 * one.Length, h.Health.OverwriteBytesReprocessed);
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
// Nothing left to find.
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_that_cuts_an_old_entry_leaves_a_fragment_that_is_never_matched()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// Longer than two old entries, shorter than three: the tail of marker 3's line survives
|
||||
// as a fragment behind the new block.
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8 is a little longer"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guard_zone_catches_a_short_block_when_the_file_grows_without_any_signal()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// Same-length replacement of marker 1 (no fragment), then an ordinary append by
|
||||
// another writer: the growth pass re-checks the last 4 KB and finds the change.
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 4 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(1, h.Health.VerifyPasses); // the full check that follows a guard hit
|
||||
Assert.True(h.Health.VerifiedBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guard_hit_rewinds_to_the_true_start_of_the_block_not_the_guard_zone()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
// ~6 KB of entries so the guard zone covers only the last few.
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 1; i <= 60; i++) sb.Append(Entry($"marker {i}"));
|
||||
h.Append(sb.ToString());
|
||||
await h.Pass();
|
||||
Assert.Equal(60, h.Matched().Count);
|
||||
|
||||
// A block over everything plus one more entry: the guard sees a change at its
|
||||
// zone start, the full check must place the rewind at offset 0.
|
||||
var block = new StringBuilder();
|
||||
for (var i = 101; i <= 161; i++) block.Append(Entry($"marker {i}"));
|
||||
h.Overwrite(0, block.ToString());
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
var matched = h.Matched();
|
||||
Assert.Equal(60 + 61, matched.Count);
|
||||
Assert.Equal(Enumerable.Range(101, 61), matched.Skip(60));
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fragment_at_the_bookmark_triggers_the_check_when_no_ledger_entry_starts_in_the_guard_zone()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
var one = Entry("marker 1");
|
||||
var big = Entry("marker 2 " + new string('x', 6000));
|
||||
h.Append(one + big);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
|
||||
// The 6 KB entry is the last ledger entry and starts before the 4 KB guard zone, so
|
||||
// the guard has nothing to check. The stale writer's block is longer than that entry:
|
||||
// the pass after it starts mid-line — a fragment — and must not match it raw.
|
||||
h.Overwrite(one.Length, Entry("marker 7 " + new string('y', 3000)) + Entry("marker 8 " + new string('z', 3100)));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hidden_block_is_found_by_the_cadence_while_a_script_is_in_flight_and_the_cadence_is_throttled()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// A platform script is pending → contention. First pass in contention verifies at once.
|
||||
h.Tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: null, exitObservedAtUtc: null, startedAtUtc: T0);
|
||||
h.Now = T0.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.VerifyPasses);
|
||||
|
||||
// Same-length replacement, no growth, no signal: only the cadence can find it —
|
||||
// and not before a second has passed since the last verification.
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Now = T0.AddMilliseconds(500);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.VerifyPasses);
|
||||
|
||||
h.Now = T0.AddMilliseconds(1200);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7 }, h.Matched());
|
||||
Assert.Equal(2, h.Health.VerifyPasses);
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_contention_or_signal_nothing_is_verified()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
h.Now = h.Now.AddSeconds(1);
|
||||
await h.Pass();
|
||||
}
|
||||
|
||||
Assert.Equal(0, h.Health.VerifyPasses);
|
||||
Assert.Equal(0, h.Health.VerifiedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_seeds_the_ledger_so_an_overwrite_after_the_restart_is_still_found()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
h.Restart();
|
||||
Assert.Equal(0, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
// First pass after the restart: nothing new to read, but the ledger is seeded from
|
||||
// the bytes behind the persisted bookmark.
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// The counter is part of the persisted health.
|
||||
h.Restart();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_clears_the_ledger_and_the_new_file_is_read_without_a_false_rewind()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
File.WriteAllText(h.LogPath, Entry("marker 7") + Entry("marker 8"), new UTF8Encoding(false));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(2, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelled_pass_does_not_lose_a_pending_check()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
|
||||
using (var cts = new CancellationTokenSource())
|
||||
{
|
||||
cts.Cancel();
|
||||
await h.Pass(cts.Token);
|
||||
}
|
||||
|
||||
// The cancelled pass touched nothing after the rewind; the next one reads it all.
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The field scenario with the shipped patterns
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Executor_end_block_written_over_a_detection_invocation_belongs_to_its_own_script()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
|
||||
// Executor E starts its platform script and waits for PowerShell.
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
|
||||
await h.Pass();
|
||||
var executorPosition = h.Length;
|
||||
|
||||
// Detection executor D appends its whole run while E waits — the tracker reads it.
|
||||
var detectionBlock = Entry("ExecutorLog AgentExecutor gets invoked") + Entry(DetectionStartLine)
|
||||
+ Entry("Powershell exit code is 3") + Entry("write output done. output = detected nothing, error = ")
|
||||
+ Entry("Agent executor completed.") + Entry(@"Writing result: False to resultFilePath: C:\x\App_cc46aa62.txt");
|
||||
h.Append(detectionBlock);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// E ends: its end block lands at its own stale position, over D's lines.
|
||||
var endBlock = Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed.");
|
||||
Assert.True(endBlock.Length < detectionBlock.Length);
|
||||
h.Overwrite(executorPosition, endBlock);
|
||||
|
||||
// Nothing visible signalled the end; the cadence (E is pending) finds the block.
|
||||
h.Now = h.Now.AddSeconds(1.5);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// IME's result line (in the other file) completes the script with E's own data.
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Hello from E", script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detection_exit_lines_read_before_the_overwrite_never_reach_the_platform_script()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 1"));
|
||||
await h.Pass();
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(DetectionStartLine)
|
||||
+ Entry("Powershell exit code is 3") + Entry("write output done. output = detected nothing, error = ") + Entry("Agent executor completed."));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// The platform result arrives without any end block ever becoming visible: held for
|
||||
// the grace, then emitted — the detection's exit code 3 must not stand in.
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Null(script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ime_result_line_hidden_by_an_executor_start_line_is_recovered_from_the_ime_log()
|
||||
{
|
||||
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
|
||||
h.Append(Entry(GeneratedLine, "17") + Entry("Launch powershell executor in user session", "17") + Entry("process id = 10608", "17"));
|
||||
await h.Pass();
|
||||
var servicePosition = h.Length;
|
||||
|
||||
// The executor process appends its telemetry start line at the true end of file.
|
||||
h.Append(Entry("[Telemetry] Telemetry is enabled. Starting mananger...", "1"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The service's first write after the script wait lands at ITS position — over the
|
||||
// executor's line; the block is longer, so the pass sees a fragment at the bookmark.
|
||||
h.Overwrite(servicePosition, Entry("Execution is done, collecting result", "17") + Entry(ResultLine, "17") + Entry("[PowerShell] Success, Result details length: 315", "17"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// Recovered on the IME side alone (this harness has no AgentExecutor.log): held for
|
||||
// the end block, emitted without an exit code after the grace.
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal("User", script.RunContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -111,9 +111,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.ProcessLogMessageForTest("Powershell exit code is 0");
|
||||
tracker.ProcessLogMessageForTest("write output done. output = [Compliant] No Classic Teams found, error = ");
|
||||
|
||||
// Authoritative IME result for the PLATFORM script (keyed by policyId).
|
||||
// Authoritative IME result for the PLATFORM script (keyed by policyId). Its own end
|
||||
// block never comes here, so the result is held until the shutdown flush — which must
|
||||
// not pick up the remediation's exit/output either.
|
||||
tracker.ProcessLogMessageForTest(
|
||||
$"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {PlatformId}, policy result = Failed");
|
||||
Assert.Empty(emitted);
|
||||
tracker.FlushPendingPlatformScriptResults(System.DateTime.UtcNow, force: true);
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
|
||||
+11
-11
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt, stdout: "Script start.\nScript end.");
|
||||
|
||||
// Well past the grace window — should emit.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal("dece354a", script.PolicyId);
|
||||
@@ -60,11 +60,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("60b43a2e", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
|
||||
// Inside the grace window — IME might still log its result; do not emit yet.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(5));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(5));
|
||||
Assert.Empty(emitted);
|
||||
|
||||
// Past the grace window — emit.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("846cea22", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
|
||||
// Shutdown flush right after exit — grace not elapsed, but force emits anyway.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("bad5c0de", exitCode: 1, exitObservedAtUtc: observedAt);
|
||||
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal(1, script.ExitCode);
|
||||
@@ -109,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Started but no exit code yet → still running, must not be emitted even on force flush.
|
||||
tracker.SeedPendingPlatformScriptForTesting("running", exitCode: null, exitObservedAtUtc: null);
|
||||
|
||||
tracker.FlushStalePlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
tracker.FlushPendingPlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
|
||||
Assert.Empty(emitted);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
|
||||
// IME finally logs its PS-SCRIPT-RESULT line — must NOT produce a second event.
|
||||
@@ -138,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
// The exit-observed timestamp must travel on the emitted state so the adapter can bind
|
||||
// the event to it instead of an unrelated "last matched" line parsed during the grace.
|
||||
@@ -156,14 +156,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
// Run 1 of policy X — fallback emits.
|
||||
tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: 0, exitObservedAtUtc: firstRun);
|
||||
tracker.FlushStalePlatformScriptResults(firstRun.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(firstRun.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
|
||||
// Run 2 of the SAME policy later in the same agent lifetime (IME re-evaluation / retry)
|
||||
// must NOT be deduped away — the fresh start clears the prior emitted-marker.
|
||||
var secondRun = firstRun.AddMinutes(30);
|
||||
tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: 0, exitObservedAtUtc: secondRun);
|
||||
tracker.FlushStalePlatformScriptResults(secondRun.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(secondRun.AddSeconds(20));
|
||||
Assert.Equal(2, emitted.Count);
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
|
||||
// Later fallback pass must find nothing pending → no duplicate.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
}
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// The IME result waits for its executor end block (session e7f3c910): PS-SCRIPT-RESULT can
|
||||
/// reach the tracker before the AgentExecutor.log end block of the same run — an overwritten
|
||||
/// block is recovered one pass after the result that triggers the ledger check, the
|
||||
/// AgentExecutor.log read of a pass may precede the exit line by milliseconds, or the poll
|
||||
/// loop stalled. Emitting at once lost the exit code and, when the late start line reopened
|
||||
/// the slot, produced a fallback duplicate 15 s later.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerResultHoldTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 22, 13, 35, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; private set; }
|
||||
public DateTime Now { get; set; } = T0;
|
||||
public List<ScriptExecutionState> Completed { get; } = new List<ScriptExecutionState>();
|
||||
public List<ScriptStartedInfo> Started { get; } = new List<ScriptStartedInfo>();
|
||||
|
||||
public Harness() { Tracker = Build(); }
|
||||
|
||||
private ImeLogTracker Build()
|
||||
{
|
||||
var t = new ImeLogTracker(_tmp.Path, new List<ImeLogPattern>(), new AgentLogger(_tmp.Path, AgentLogLevel.Info), stateDirectory: _tmp.Path);
|
||||
t.UtcNowProvider = () => Now;
|
||||
t.OnScriptCompleted = s => Completed.Add(s);
|
||||
t.OnScriptStarted = s => Started.Add(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Agent restart over the same state directory.</summary>
|
||||
public void Restart()
|
||||
{
|
||||
Tracker.SaveStateForTest();
|
||||
Tracker.Dispose();
|
||||
Tracker = Build();
|
||||
Tracker.LoadStateForTest();
|
||||
}
|
||||
|
||||
/// <summary>A start line (IME "Script file … is generated" or the executor's argument line) with its source timestamp.</summary>
|
||||
public void Start(string id, DateTime lineTs)
|
||||
{
|
||||
Tracker.LastMatchedLogTimestamp = lineTs;
|
||||
Tracker.NextTestEntry();
|
||||
Tracker.HandlePlatformScriptStarted(id);
|
||||
}
|
||||
|
||||
public void Result(string id, DateTime lineTs, string result = "Success")
|
||||
=> Tracker.CompletePlatformScriptFromImeResultForTesting(id, result, lineTs);
|
||||
|
||||
public void Exit(string id, DateTime lineTs, int exitCode = 0)
|
||||
=> Tracker.RecordPlatformScriptExitCodeForTesting(id, exitCode, lineTs);
|
||||
|
||||
/// <summary>The end-of-pass flush at the harness clock.</summary>
|
||||
public void Flush(bool force = false) => Tracker.FlushPendingPlatformScriptResults(Now, force);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_before_the_end_block_is_held_and_emits_once_with_the_exit_code()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("376ee51a", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = T0.AddSeconds(2.1);
|
||||
h.Flush();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The end block surfaces on the next pass (ledger rewind or plain read lag).
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.2);
|
||||
h.Flush();
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal(T0.AddSeconds(2), script.ResultObservedAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", script.ResultPatternId);
|
||||
Assert.Equal(T0, script.StartedAtUtc);
|
||||
|
||||
// Nothing fires twice: later passes, a repeated result line, the 15 s fallback window.
|
||||
h.Now = T0.AddSeconds(30);
|
||||
h.Flush();
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_with_the_end_block_already_read_emits_at_once()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("847da54e", T0);
|
||||
h.Exit("847da54e", T0.AddSeconds(2.3));
|
||||
h.Now = T0.AddSeconds(2.31);
|
||||
h.Result("847da54e", T0.AddSeconds(2.31));
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Null(script.ResultHeldSinceUtc);
|
||||
Assert.Equal(T0.AddSeconds(2.31), script.ResultObservedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_result_emits_without_exit_code_after_the_grace()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("784fcaf5", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("784fcaf5", T0.AddSeconds(2));
|
||||
|
||||
h.Now = T0.AddSeconds(2).Add(ImeLogTracker.PlatformScriptEndBlockGrace).AddMilliseconds(-100);
|
||||
h.Flush();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Now = T0.AddSeconds(2).Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Flush();
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
|
||||
// An exit line surfacing after the emit is dropped — no second event, no fallback.
|
||||
h.Exit("784fcaf5", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(40);
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shutdown_flush_emits_a_held_result_at_once()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("a52e4f47", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("a52e4f47", T0.AddSeconds(2), "Failed");
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Flush(force: true);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("Failed", script.Result);
|
||||
Assert.Null(script.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_start_line_of_an_emitted_run_opens_no_new_run()
|
||||
{
|
||||
// Session e7f3c910, 376ee51a: the result was emitted, THEN the executor's start and
|
||||
// exit lines of the same run were read (a 2 s poll stall) — the start opened a second
|
||||
// slot and the exit code became a fallback duplicate 15 s later.
|
||||
using var h = new Harness();
|
||||
h.Start("376ee51a", T0);
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Single(h.Completed);
|
||||
Assert.Single(h.Started);
|
||||
|
||||
h.Start("376ee51a", T0.AddMilliseconds(300));
|
||||
Assert.Single(h.Started);
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(30);
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_line_newer_than_the_emitted_result_is_the_next_run()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("338d9434", T0);
|
||||
h.Exit("338d9434", T0.AddSeconds(1));
|
||||
h.Now = T0.AddSeconds(1);
|
||||
h.Result("338d9434", T0.AddSeconds(1));
|
||||
Assert.Single(h.Completed);
|
||||
|
||||
// IME re-runs the policy after the user signed in.
|
||||
h.Start("338d9434", T0.AddMinutes(7));
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
h.Exit("338d9434", T0.AddMinutes(7).AddSeconds(3));
|
||||
h.Now = T0.AddMinutes(7).AddSeconds(3);
|
||||
h.Result("338d9434", T0.AddMinutes(7).AddSeconds(3));
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
Assert.Equal(T0.AddMinutes(7), h.Completed[1].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newer_start_line_closes_a_held_result_and_starts_the_next_run()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("c81b8053", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("c81b8053", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The end block never surfaced before the policy ran again.
|
||||
h.Start("c81b8053", T0.AddSeconds(4));
|
||||
var first = Assert.Single(h.Completed);
|
||||
Assert.Null(first.ExitCode);
|
||||
Assert.Equal(T0, first.StartedAtUtc);
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
|
||||
h.Exit("c81b8053", T0.AddSeconds(6));
|
||||
h.Now = T0.AddSeconds(6);
|
||||
h.Result("c81b8053", T0.AddSeconds(6));
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
Assert.Equal(0, h.Completed[1].ExitCode);
|
||||
Assert.Equal(T0.AddSeconds(4), h.Completed[1].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_without_a_start_takes_the_recovered_start_line_and_end_block()
|
||||
{
|
||||
// Both the start block and the end block were hidden when the result arrived; the
|
||||
// ledger rewind the result triggers brings both back on the next pass.
|
||||
using var h = new Harness();
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("d39eeef3", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Start("d39eeef3", T0);
|
||||
Assert.Empty(h.Started);
|
||||
h.Exit("d39eeef3", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.2);
|
||||
h.Flush();
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(T0, script.StartedAtUtc);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_result_survives_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("832e27bc", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("832e27bc", T0.AddSeconds(2));
|
||||
h.Restart();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Exit("832e27bc", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.5);
|
||||
h.Flush();
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal(T0.AddSeconds(2), script.ResultObservedAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", script.ResultPatternId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emitted_marker_keeps_the_run_timestamp_across_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("89b3cbcd", T0);
|
||||
h.Exit("89b3cbcd", T0.AddSeconds(1));
|
||||
h.Now = T0.AddSeconds(1);
|
||||
h.Result("89b3cbcd", T0.AddSeconds(1));
|
||||
h.Restart();
|
||||
|
||||
h.Start("89b3cbcd", T0.AddMilliseconds(500)); // late line of the emitted run
|
||||
Assert.Single(h.Started);
|
||||
h.Start("89b3cbcd", T0.AddMinutes(5)); // the next run
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_list_from_an_older_state_file_still_dedups()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var persistence = new ImeTrackerStatePersistence(tmp.Path, new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
persistence.Save(new ImeTrackerStateData { PlatformScriptResultEmitted = new List<string> { "policyZ" } });
|
||||
|
||||
var completed = new List<ScriptExecutionState>();
|
||||
var started = new List<ScriptStartedInfo>();
|
||||
var tracker = new ImeLogTracker(tmp.Path, new List<ImeLogPattern>(), new AgentLogger(tmp.Path, AgentLogLevel.Info), stateDirectory: tmp.Path);
|
||||
tracker.OnScriptCompleted = s => completed.Add(s);
|
||||
tracker.OnScriptStarted = s => started.Add(s);
|
||||
tracker.LoadStateForTest();
|
||||
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyZ", "Success", T0);
|
||||
Assert.Empty(completed);
|
||||
|
||||
// Without a timestamp on the marker every start line is a new run, as before.
|
||||
tracker.LastMatchedLogTimestamp = T0;
|
||||
tracker.HandlePlatformScriptStarted("policyZ");
|
||||
Assert.Single(started);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Run 1: exit code seen, shutdown force-flush emits the fallback completion.
|
||||
var tracker1 = BuildTracker(tmp, out var emitted1);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyA", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker1.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
Assert.Single(emitted1);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
@@ -68,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Restarted tracker continues the grace window instead of dropping the script.
|
||||
var tracker2 = BuildTracker(tmp, out var emitted2);
|
||||
tracker2.LoadStateForTest();
|
||||
tracker2.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker2.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted2);
|
||||
Assert.Equal("policyB", script.PolicyId);
|
||||
@@ -84,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
var tracker1 = BuildTracker(tmp, out _);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyC", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker1.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
// Restart, then a genuine RE-RUN of the same policy (fresh start clears the marker).
|
||||
@@ -109,8 +109,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
tracker.LoadStateForTest(); // must not throw
|
||||
|
||||
// Degrades to pre-fix behavior: no markers restored, normal emission works.
|
||||
// Degrades to pre-fix behavior: no markers restored, normal emission works (the
|
||||
// result waits for its end block until the shutdown flush).
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyD", "Success");
|
||||
tracker.FlushPendingPlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
@@ -128,8 +130,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyE", "Failed", run2Start.AddMinutes(-5));
|
||||
Assert.Empty(emitted);
|
||||
|
||||
// Run 2's own result (newer than its start) still emits normally.
|
||||
// Run 2's own result (newer than its start) still emits normally — held for the end
|
||||
// block until the shutdown flush, since none is seeded here.
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyE", "Success", run2Start.AddMinutes(2));
|
||||
tracker.FlushPendingPlatformScriptResults(run2Start.AddMinutes(2), force: true);
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal(run2Start, script.StartedAtUtc);
|
||||
|
||||
+36
@@ -265,6 +265,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deadline_fired_late_keeps_the_due_time_as_stamp_and_carries_it_in_the_payload()
|
||||
{
|
||||
// A deadline that was due while the device slept (or the agent was down) fires only
|
||||
// afterwards. The signal keeps the due time as OccurredAtUtc (D-238: the outage must not
|
||||
// become session duration) and repeats it in the payload so the stale-fire guards
|
||||
// identify the incarnation independently of the stamp.
|
||||
using var rig = new EnrollmentOrchestratorRig(At);
|
||||
var sut = rig.Build();
|
||||
sut.Start();
|
||||
|
||||
var dueAt = At.AddMinutes(-5);
|
||||
var scheduler = GetScheduler(sut);
|
||||
scheduler.Schedule(new ActiveDeadline(
|
||||
name: "hello_safety",
|
||||
dueAtUtc: dueAt,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = "hello_safety" }));
|
||||
|
||||
var signalLog = GetSignalLog(sut);
|
||||
Assert.True(SpinWait.SpinUntil(() => ContainsDeadlineFired(signalLog), 3000));
|
||||
|
||||
DecisionSignal? fired = null;
|
||||
foreach (var sig in signalLog.ReadAll())
|
||||
{
|
||||
if (sig.Kind == DecisionSignalKind.DeadlineFired) { fired = sig; break; }
|
||||
}
|
||||
Assert.NotNull(fired);
|
||||
Assert.Equal(dueAt, fired!.OccurredAtUtc);
|
||||
Assert.NotNull(fired.Payload);
|
||||
Assert.Equal("hello_safety", fired.Payload![SignalPayloadKeys.Deadline]);
|
||||
Assert.Equal(dueAt.ToString("O"), fired.Payload[SignalPayloadKeys.DeadlineDueAtUtc]);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
// ========================================================================= Quarantine
|
||||
|
||||
[Fact]
|
||||
|
||||
+2
@@ -247,6 +247,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow;
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("beef7777", "Success", ClockNow);
|
||||
// No end block is seeded — the held result goes out with the shutdown flush.
|
||||
f.Tracker.FlushPendingPlatformScriptResults(f.Clock.UtcNow, force: true);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
// Without the hardening the slot kept the 7-day-old start: the duration would have
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// A held platform-script completion (IME result read before the executor end block) is
|
||||
/// emitted at the end of a later pass, when the tracker's "last matched" line is an unrelated
|
||||
/// one — the event stays bound to the result line's timestamp and pattern.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterResultHoldTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 9, 10, 22, 13, 40, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Held_completion_is_bound_to_the_result_line_not_to_the_last_matched_one()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startTs = ClockNow.AddSeconds(-95);
|
||||
var resultTs = ClockNow.AddSeconds(-3);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = startTs;
|
||||
f.Tracker.HandlePlatformScriptStarted("376ee51a");
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = resultTs;
|
||||
f.Tracker.LastMatchedPatternId = "PS-SCRIPT-RESULT";
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("376ee51a", "Success", resultTs);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
|
||||
// Other lines were matched before the end block was read and the pass ended.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-1);
|
||||
f.Tracker.LastMatchedPatternId = "IME-DOWNLOADING";
|
||||
f.Tracker.RecordPlatformScriptExitCodeForTesting("376ee51a", 0, resultTs.AddMilliseconds(-8));
|
||||
f.Tracker.FlushPendingPlatformScriptResults(f.Clock.UtcNow);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(resultTs, info.OccurredAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", info.Payload!["patternId"]);
|
||||
Assert.Equal("0", info.Payload["exitCode"]);
|
||||
Assert.Equal("ime_policy_result", info.Payload["resultSource"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly bool _hashLines;
|
||||
private readonly byte[] _readBuffer = new byte[ReadBufferSize];
|
||||
private int _readPos;
|
||||
private int _readLen;
|
||||
@@ -41,21 +42,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private byte[] _line = new byte[4096];
|
||||
private int _lineLen;
|
||||
private ulong _lineHash;
|
||||
|
||||
/// <param name="stream">Seekable stream positioned at the first byte to read.</param>
|
||||
/// <param name="maxLineBytes">Upper bound of bytes kept per line; the remainder is discarded.</param>
|
||||
public BoundedLineReader(Stream stream, int maxLineBytes)
|
||||
/// <param name="hashLines">Fingerprint the raw bytes of every line (<see cref="LastLineHash"/>) — the
|
||||
/// tracker's byte ledger needs it for multi-writer files only.</param>
|
||||
public BoundedLineReader(Stream stream, int maxLineBytes, bool hashLines = false)
|
||||
{
|
||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
|
||||
_stream = stream;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_hashLines = hashLines;
|
||||
_readBufferFileOffset = stream.Position;
|
||||
}
|
||||
|
||||
/// <summary>Cap in bytes applied to every line.</summary>
|
||||
public int MaxLineBytes => _maxLineBytes;
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a 64 over the bytes the most recently returned line was decoded from (BOM and
|
||||
/// line terminator excluded; the capped prefix of a truncated line). Zero unless the
|
||||
/// reader was created with hashing on.
|
||||
/// </summary>
|
||||
public ulong LastLineHash => _lineHash;
|
||||
|
||||
/// <summary>Absolute file offset of the next unread byte (exact — no decoder read-ahead).</summary>
|
||||
public long Position => _readBufferFileOffset + _readPos;
|
||||
|
||||
@@ -78,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastLineTerminated = false;
|
||||
LastLineTruncated = false;
|
||||
_lineLen = 0;
|
||||
_lineHash = _hashLines ? Fnv1a64.OffsetBasis : 0UL;
|
||||
var sawAnyByte = false;
|
||||
|
||||
while (true)
|
||||
@@ -154,6 +167,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// tail (and thus any CR) discarded, so the check is skipped there.
|
||||
if (LastLineTerminated && !LastLineTruncated && len > 0 && _line[start + len - 1] == (byte)'\r')
|
||||
len--;
|
||||
if (_hashLines)
|
||||
{
|
||||
// Fingerprint exactly the bytes the string is decoded from, so a tail that was
|
||||
// first read unterminated and later gained its CRLF hashes the same.
|
||||
var h = Fnv1a64.OffsetBasis;
|
||||
var end = start + len;
|
||||
for (var i = start; i < end; i++)
|
||||
h = (h ^ _line[i]) * Fnv1a64.Prime;
|
||||
_lineHash = h;
|
||||
}
|
||||
return len == 0 ? string.Empty : Encoding.UTF8.GetString(_line, start, len);
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>FNV-1a 64-bit — the entry fingerprint used by the tracker's byte ledger.</summary>
|
||||
internal static class Fnv1a64
|
||||
{
|
||||
public const ulong OffsetBasis = 14695981039346656037UL;
|
||||
public const ulong Prime = 1099511628211UL;
|
||||
|
||||
public static ulong Fold(ulong hash, ulong value) => (hash ^ value) * Prime;
|
||||
}
|
||||
|
||||
/// <summary>One assembled CMTrace entry: its byte range in the file, the fingerprint of those bytes and the text.</summary>
|
||||
internal struct AssembledEntry
|
||||
{
|
||||
public long Offset;
|
||||
public int Length;
|
||||
public ulong Hash;
|
||||
public string Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns physical lines into CMTrace entries: a line is one entry, unless it opens
|
||||
/// <c><![LOG[</c> without its <c>]LOG]!></c> close — then the continuation lines up to the
|
||||
/// closing one belong to it. Oversized lines and capped entries are dropped together with
|
||||
/// their remaining continuation lines, so no fragment is ever matched as an entry.
|
||||
/// <para>
|
||||
/// The read loop and the ledger verification (<c>ImeLogTracker.Overwrite.cs</c>) both feed
|
||||
/// this class, which is what makes their entry boundaries — and therefore the fingerprints
|
||||
/// they compare — identical by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEntryAssembler
|
||||
{
|
||||
public enum Outcome
|
||||
{
|
||||
/// <summary>The line was absorbed into an open multiline entry.</summary>
|
||||
Buffered,
|
||||
/// <summary>An entry is complete.</summary>
|
||||
Entry,
|
||||
/// <summary>A line over the byte cap was dropped (its entry with it).</summary>
|
||||
OversizedDropped,
|
||||
/// <summary>An open multiline entry hit the line/char cap and was dropped.</summary>
|
||||
CapDropped,
|
||||
/// <summary>A continuation line of a dropped entry was skipped.</summary>
|
||||
Skipped,
|
||||
}
|
||||
|
||||
private const string LogOpen = "<![LOG[";
|
||||
private const string LogClose = "]LOG]!>";
|
||||
|
||||
private readonly int _maxLines;
|
||||
private readonly int _maxChars;
|
||||
|
||||
private StringBuilder _buffer;
|
||||
private int _lineCount;
|
||||
private long _entryStart;
|
||||
private ulong _entryHash;
|
||||
private bool _skippingDroppedEntry;
|
||||
|
||||
public CmTraceEntryAssembler(int maxMultiLineBufferLines, int maxEntryBytes)
|
||||
{
|
||||
_maxLines = maxMultiLineBufferLines;
|
||||
_maxChars = maxEntryBytes;
|
||||
}
|
||||
|
||||
/// <summary>True while a multiline entry is being assembled.</summary>
|
||||
public bool HasOpenEntry => _buffer != null;
|
||||
|
||||
/// <summary>File offset of the open multiline entry (valid while <see cref="HasOpenEntry"/>).</summary>
|
||||
public long OpenEntryStart => _entryStart;
|
||||
|
||||
/// <summary>Line count of the entry dropped by the last <see cref="Outcome.CapDropped"/>.</summary>
|
||||
public int DroppedLines { get; private set; }
|
||||
|
||||
/// <summary>Char count of the entry dropped by the last <see cref="Outcome.CapDropped"/>.</summary>
|
||||
public int DroppedChars { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds one physical line. <paramref name="lineStart"/> / <paramref name="positionAfter"/>
|
||||
/// are the line's file offsets (start, next unread byte), <paramref name="lineHash"/> the
|
||||
/// reader's raw-byte fingerprint of the line.
|
||||
/// </summary>
|
||||
public Outcome Feed(string line, long lineStart, long positionAfter, bool truncated, ulong lineHash, out AssembledEntry entry)
|
||||
{
|
||||
entry = default(AssembledEntry);
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
// The capped prefix only tells whether the line opened an entry whose remaining
|
||||
// lines must be skipped rather than matched as raw text.
|
||||
if (_buffer != null)
|
||||
{
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
_skippingDroppedEntry = true;
|
||||
}
|
||||
else if (line.StartsWith(LogOpen) && !line.Contains(LogClose))
|
||||
{
|
||||
_skippingDroppedEntry = true;
|
||||
}
|
||||
return Outcome.OversizedDropped;
|
||||
}
|
||||
|
||||
if (_skippingDroppedEntry)
|
||||
{
|
||||
// A line opening a new entry ends the skip and is processed itself — checked
|
||||
// BEFORE the close-tag test, because a complete single-line entry contains both.
|
||||
if (line.StartsWith(LogOpen))
|
||||
_skippingDroppedEntry = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains(LogClose)) _skippingDroppedEntry = false;
|
||||
return Outcome.Skipped;
|
||||
}
|
||||
}
|
||||
|
||||
if (_buffer != null)
|
||||
{
|
||||
_buffer.Append('\n').Append(line);
|
||||
_lineCount++;
|
||||
_entryHash = Fnv1a64.Fold(_entryHash, lineHash);
|
||||
|
||||
if (line.Contains(LogClose))
|
||||
{
|
||||
entry = new AssembledEntry
|
||||
{
|
||||
Offset = _entryStart,
|
||||
Length = (int)(positionAfter - _entryStart),
|
||||
Hash = _entryHash,
|
||||
Text = _buffer.ToString(),
|
||||
};
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
return Outcome.Entry;
|
||||
}
|
||||
|
||||
if (_lineCount >= _maxLines || _buffer.Length >= _maxChars)
|
||||
{
|
||||
DroppedLines = _lineCount;
|
||||
DroppedChars = _buffer.Length;
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
_skippingDroppedEntry = true;
|
||||
return Outcome.CapDropped;
|
||||
}
|
||||
|
||||
return Outcome.Buffered;
|
||||
}
|
||||
|
||||
if (line.StartsWith(LogOpen) && !line.Contains(LogClose))
|
||||
{
|
||||
_buffer = new StringBuilder(line);
|
||||
_lineCount = 1;
|
||||
_entryStart = lineStart;
|
||||
_entryHash = lineHash;
|
||||
return Outcome.Buffered;
|
||||
}
|
||||
|
||||
entry = new AssembledEntry
|
||||
{
|
||||
Offset = lineStart,
|
||||
Length = (int)(positionAfter - lineStart),
|
||||
Hash = lineHash,
|
||||
Text = line,
|
||||
};
|
||||
return Outcome.Entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
-177
@@ -222,20 +222,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// PowerShell script tracking handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Returns the current platform-script accumulator, or null when no platform script is active.
|
||||
// Health-script (remediation) state is no longer tracked line-by-line — the HS-NEW-RESULT
|
||||
// pattern delivers the full pre-detection / remediation / post-detection JSON in one shot
|
||||
// via HandleHealthScriptResult. The platform branch keeps its line-by-line accumulator
|
||||
// because PS-* patterns (PS-SCRIPT-CONTEXT / PS-SCRIPT-EXITCODE / PS-AGENT-OUTPUT / …) still
|
||||
// arrive across multiple log lines.
|
||||
private ScriptExecutionState GetCurrentPlatformScript()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
|
||||
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
|
||||
return state;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
@@ -282,27 +268,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// a regex Match).
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
// enrollment ended) leaves a pending slot with a days-old StartedAtUtc. When the
|
||||
// same policy genuinely re-runs now, the fresh start must begin a NEW execution —
|
||||
// otherwise the ancient start pairs with the fresh completion and the duration
|
||||
// lies. > 24 h mirrors the adapter's source-timestamp staleness clamp.
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot)
|
||||
&& pendingSlot.StartedAtUtc.HasValue
|
||||
&& currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
|
||||
// A start line older than the result this policy already emitted is the late start
|
||||
// line of THAT run: its AgentExecutor block surfaced after the IME result — the two
|
||||
// files are read in turn (AgentExecutor.log first, but a script's exit line and IME's
|
||||
// result line are 8–30 ms apart), a ledger rewind recovered the block, or the poll
|
||||
// loop stalled (session e7f3c910: the late line opened a second slot, cleared the
|
||||
// emitted-marker and turned the exit code that followed into a fallback duplicate
|
||||
// 15 s later). It owns the lines behind it in its file and opens nothing. Strictly
|
||||
// older: a start line at the result's own timestamp is the next run (source lines
|
||||
// never tie; a low-resolution clock in a test may).
|
||||
if (_platformScriptResultEmitted.TryGetValue(id, out var emittedResultTs) && currentStartTs < emittedResultTs)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: discarding stale pending platform-script slot for {id} (started {pendingSlot.StartedAtUtc:o})");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
_logger.Debug($"ImeLogTracker: platform script start line for {id} (source: {source ?? "ime"}) predates its emitted result — late line of that run, not a new one");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot))
|
||||
{
|
||||
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
// enrollment ended) leaves a pending slot with a days-old StartedAtUtc. When the
|
||||
// same policy genuinely re-runs now, the fresh start must begin a NEW execution —
|
||||
// otherwise the ancient start pairs with the fresh completion and the duration
|
||||
// lies. > 24 h mirrors the adapter's source-timestamp staleness clamp.
|
||||
_logger.Debug($"ImeLogTracker: discarding stale pending platform-script slot for {id} (started {pendingSlot.StartedAtUtc:o})");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
}
|
||||
else if (pendingSlot.Result != null && pendingSlot.ResultObservedAtUtc.HasValue && currentStartTs >= pendingSlot.ResultObservedAtUtc.Value)
|
||||
{
|
||||
// The held run is over — a start line at or after its result is the next run.
|
||||
// Emit what the held run has; its end block did not surface in time.
|
||||
EmitPlatformScriptResult(pendingSlot, "the next run of this policy started");
|
||||
}
|
||||
else if (!pendingSlot.StartedAtUtc.HasValue)
|
||||
{
|
||||
// The slot was opened by its result while the start block was still hidden;
|
||||
// the recovered start line dates the run.
|
||||
pendingSlot.StartedAtUtc = currentStartTs;
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
{
|
||||
// A fresh start for this policy (no pending entry) begins a NEW execution.
|
||||
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
|
||||
// the same platform-script policy within one agent lifetime are not silently
|
||||
// deduped away. Within a single run the start always precedes exit/result, so
|
||||
// this never clears the current run's own marker.
|
||||
// deduped away. A start line of the emitted run itself never reaches this point
|
||||
// (the guard above), so this never clears the current run's own marker.
|
||||
_platformScriptResultEmitted.Remove(id);
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
{
|
||||
@@ -320,7 +338,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
_lastPlatformScriptPolicyId = id;
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
// Started lines fire twice per script (agentexecutor + ime source) and carry no
|
||||
// outcome — the matching `platform script completed` line below carries result+exit
|
||||
// and stays on Info. Keep starts on Debug so Info reflects script outcomes only.
|
||||
@@ -329,42 +348,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
|
||||
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
|
||||
// single-slot _pendingHealthScript; platform-script lines feed _pendingPlatformScripts.
|
||||
// single-slot _pendingHealthScript (the HS-NEW-RESULT JSON delivers the full cycle in one
|
||||
// shot; the slot only enriches the early HS-COMPLIANCE signal). Platform-script lines feed
|
||||
// _pendingPlatformScripts, and the slot that owns a line is decided by file position (see
|
||||
// ImeLogTracker.Overwrite.cs), never by which script happened to start last.
|
||||
private ScriptExecutionState GetCurrentScriptForLineUpdate(Dictionary<string, string> parameters)
|
||||
=> GetCurrentScriptForLineUpdate(parameters, out _);
|
||||
|
||||
// ownerPolicyId names the platform script that owns the line by file position even when
|
||||
// its pending slot is gone (the completion was already emitted); null for health-script
|
||||
// lines and for lines no platform invocation owns.
|
||||
private ScriptExecutionState GetCurrentScriptForLineUpdate(Dictionary<string, string> parameters, out string ownerPolicyId)
|
||||
{
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
|
||||
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
|
||||
? _pendingHealthScript
|
||||
: GetCurrentPlatformScript();
|
||||
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
return _pendingHealthScript;
|
||||
}
|
||||
return ResolvePlatformScriptForCurrentEntry(out ownerPolicyId);
|
||||
}
|
||||
|
||||
// Session 6b4993e5 fix — platform-script stdout/exit cross-contamination.
|
||||
//
|
||||
// AgentExecutor.exe hosts BOTH platform scripts (…\Policies\Scripts\<id>.ps1) and
|
||||
// proactive-remediation scripts (…\IMECache\HealthScripts\<id>\detect.ps1), and writes
|
||||
// their lines interleaved into the shared AgentExecutor.log. The policyId-less
|
||||
// PS-AGENT-EXITCODE ("Powershell exit code is N") and PS-AGENT-OUTPUT ("write output
|
||||
// done. output = …") lines are routed to whichever platform script
|
||||
// _lastPlatformScriptPolicyId points at. A remediation invocation's start
|
||||
// ("Adding argument remediationScript …") does NOT match PS-AGENT-SCRIPT-START, so it
|
||||
// never moves the pointer — its exit/output then bleed into the last-started PLATFORM
|
||||
// script's slot (observed: platform c3e0124c emitted result=Failed but with exit 0 +
|
||||
// stdout "[Compliant] No Classic Teams found" — the Teams remediation's output).
|
||||
//
|
||||
// Each AgentExecutor process logs "ExecutorLog AgentExecutor gets invoked" first, so a
|
||||
// new banner means the previous invocation's line-capture context is over. Clearing the
|
||||
// pointer here scopes line-capture to a single invocation: the immediately following
|
||||
// "Adding argument powershell …\Policies\Scripts\<id>.ps1" (PS-AGENT-SCRIPT-START)
|
||||
// re-establishes it for a platform invocation, while a remediation invocation leaves it
|
||||
// null so its exit/output are dropped (the remediation's authoritative data still
|
||||
// arrives via HS-NEW-RESULT). The platform script's final result is unaffected — it is
|
||||
// keyed by policyId via PS-SCRIPT-RESULT, not by this pointer.
|
||||
private void HandleAgentExecutorInvocationBoundary()
|
||||
// AgentExecutor.exe hosts platform scripts (…\Policies\Scripts\<id>.ps1) as well as
|
||||
// detection, requirement and proactive-remediation scripts, and its policyId-less
|
||||
// PS-AGENT-EXITCODE / PS-AGENT-OUTPUT lines belong to whichever invocation owns that
|
||||
// file position. The banner closes the previous invocation; the argument line that
|
||||
// follows opens the next one — as a platform script when PS-AGENT-SCRIPT-START matched
|
||||
// it, otherwise as an invocation whose exit/output are not captured (a remediation's
|
||||
// authoritative data arrives via HS-NEW-RESULT; session 6b4993e5 saw its output bleed
|
||||
// into a platform slot). "Agent executor completed" closes the invocation again.
|
||||
private void HandleAgentExecutorInvocation()
|
||||
{
|
||||
if (_lastPlatformScriptPolicyId == null) return;
|
||||
_logger.Debug(
|
||||
$"ImeLogTracker: AgentExecutor invocation boundary — clearing platform-script line-capture pointer (was {_lastPlatformScriptPolicyId}).");
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
RecordInvocationMarker(null, isClose: true);
|
||||
NoteInvocationActivity();
|
||||
}
|
||||
|
||||
private void HandleAgentInvocationArgument(string kind)
|
||||
{
|
||||
RecordInvocationMarker(null, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
if (!string.IsNullOrEmpty(kind) && !string.Equals(kind, "powershell", StringComparison.OrdinalIgnoreCase))
|
||||
_logger.Debug($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
}
|
||||
|
||||
private void HandleAgentExecutorCompleted()
|
||||
{
|
||||
RecordInvocationMarker(null, isClose: true);
|
||||
// The end block was written at the executor's own stream position — if that lay
|
||||
// behind the bookmark, the ledger check on the next pass finds it.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
|
||||
@@ -372,6 +404,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var context = match.Groups["context"]?.Value;
|
||||
if (string.IsNullOrEmpty(context)) return;
|
||||
|
||||
NoteInvocationActivity();
|
||||
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script != null)
|
||||
@@ -386,18 +419,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var exitCodeStr = match.Groups["exitCode"]?.Value;
|
||||
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
|
||||
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
var script = GetCurrentScriptForLineUpdate(parameters, out var ownerPolicyId);
|
||||
if (script != null)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
// Stamp when we learned the script's exit code so the deadline-based fallback
|
||||
// (FlushStalePlatformScriptResults) can fire if IME never logs its authoritative
|
||||
// PS-SCRIPT-RESULT line in time. Prefer the source CMTrace timestamp (already
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
script.ExitObservedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider());
|
||||
else if (ownerPolicyId != null)
|
||||
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
|
||||
// An exit code means the executor just wrote its end block and IME is about to
|
||||
// write the result — both land at a stale stream position when another process
|
||||
// appended meanwhile. Check the ledgers on the next pass.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
// Stamp when we learned the script's exit code so the deadline-based fallback
|
||||
// (FlushPendingPlatformScriptResults) can fire if IME never logs its authoritative
|
||||
// PS-SCRIPT-RESULT line in time. Prefer the source CMTrace timestamp (already
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
script.ExitObservedAtUtc = observedAtUtc;
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
|
||||
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
|
||||
@@ -446,22 +488,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
// Dedup: the deadline-based fallback (FlushStalePlatformScriptResults) may already have
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative IME <c>PS-SCRIPT-RESULT</c> line of a platform script. Emits at once
|
||||
/// when the executor end block (exit code, output) has been read already — the normal
|
||||
/// order of the two files — and otherwise holds the completion: the end block reaches the
|
||||
/// tracker after the result whenever a concurrent writer overwrote it (the check this
|
||||
/// result triggers recovers it one pass later), when the AgentExecutor.log read of a pass
|
||||
/// simply preceded the exit line by a few milliseconds, or when the poll loop stalled.
|
||||
/// Emitting immediately lost the exit code in all three cases and, once the late start
|
||||
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
|
||||
/// </summary>
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId)
|
||||
{
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
|
||||
// emitted this script from its AgentExecutor exit code because IME's authoritative
|
||||
// PS-SCRIPT-RESULT line arrived late. The fallback carries the same exit code + stdout,
|
||||
// so re-emitting here would duplicate the timeline entry and inflate counts. Drop the
|
||||
// now-redundant pending entry and skip.
|
||||
if (_platformScriptResultEmitted.Contains(id))
|
||||
if (_platformScriptResultEmitted.ContainsKey(policyId))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {id} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge with pending AgentExecutor data if available
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var script))
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
{
|
||||
// Stale-result guard: on an intra-lifetime re-run of the same policy, run 1's
|
||||
// late PS-SCRIPT-RESULT can arrive after run 2's start line already created a
|
||||
@@ -471,10 +527,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// line in the source log, so a result older than the pending slot's start
|
||||
// belongs to a previous run — drop it and keep the live slot intact.
|
||||
if (script.StartedAtUtc.HasValue
|
||||
&& LastMatchedLogTimestamp.HasValue
|
||||
&& LastMatchedLogTimestamp.Value < script.StartedAtUtc.Value)
|
||||
&& resultLineTimestampUtc.HasValue
|
||||
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {id} result predates the current run's start — stale result from a previous run, skipping");
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} result predates the current run's start — stale result from a previous run, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -482,70 +538,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
script = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform"
|
||||
};
|
||||
_pendingPlatformScripts[policyId] = script;
|
||||
}
|
||||
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
|
||||
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(id);
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
|
||||
script.ResultPatternId = patternId;
|
||||
_stateDirty = true;
|
||||
|
||||
if (script.ExitCode.HasValue)
|
||||
{
|
||||
EmitPlatformScriptResult(script);
|
||||
}
|
||||
else
|
||||
{
|
||||
script.ResultHeldSinceUtc = UtcNowProvider();
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} result={result} seen before its executor end block — holding the completion up to {PlatformScriptEndBlockGrace.TotalSeconds:F0}s for the exit code");
|
||||
}
|
||||
// The result block is IME's first write after the script wait — the executor's end
|
||||
// block in the other file may have landed behind the bookmark at the same time.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one emit path for platform-script completions (IME result, held result, exit-code
|
||||
/// fallback): event, emitted-marker with the run's source timestamp, slot closed.
|
||||
/// </summary>
|
||||
private void EmitPlatformScriptResult(ScriptExecutionState script, string note = null)
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted[script.PolicyId] = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grace period after a platform script's AgentExecutor exit code is observed before
|
||||
/// <see cref="FlushStalePlatformScriptResults"/> emits a completion from that exit code.
|
||||
/// IME logs its authoritative <c>PS-SCRIPT-RESULT</c> line within ~1 s of exit on healthy
|
||||
/// reporting cycles, so 15 s lets the normal path win virtually always — the fallback is a
|
||||
/// true safety net for the case where IME has not flushed its batch-send to the Microsoft
|
||||
/// service before the (often short) Autopilot enrollment ends and the agent terminates.
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits a completion from that exit code.
|
||||
/// IME logs its authoritative <c>PS-SCRIPT-RESULT</c> line within ~1 s of exit; when it
|
||||
/// is missing after 15 s it was hidden by a concurrent writer (the ledger check in
|
||||
/// <c>ImeLogTracker.Overwrite.cs</c> recovers that line) or the enrollment ended before
|
||||
/// IME wrote it — this fallback is the safety net for the remaining cases.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PlatformScriptResultGrace = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Policy IDs of platform scripts already emitted (via either the authoritative
|
||||
/// PS-SCRIPT-RESULT path or the exit-code fallback). Guards against double emission when
|
||||
/// both fire for the same script.
|
||||
/// How long an IME result waits for the executor end block of its run before the
|
||||
/// completion goes out without an exit code. An overwritten end block surfaces on the
|
||||
/// pass after the result (100 ms, the result triggers the ledger check); a stalled poll
|
||||
/// loop was measured at 2 s (session e7f3c910, VM at 100 % CPU). Well below the 15 s
|
||||
/// exit-code fallback, and the event keeps the result line's timestamp — only its
|
||||
/// delivery waits.
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _platformScriptResultEmitted =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
internal static readonly TimeSpan PlatformScriptEndBlockGrace = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a <c>script_completed</c>/<c>script_failed</c> for every pending platform script
|
||||
/// that has a known AgentExecutor exit code but whose IME <c>PS-SCRIPT-RESULT</c> line has
|
||||
/// not arrived within <see cref="PlatformScriptResultGrace"/>. Without this, scripts that
|
||||
/// completed shortly before the agent terminates are silently dropped (the
|
||||
/// <c>_pendingPlatformScripts</c> buffer is neither persisted nor flushed on dispose), so a
|
||||
/// device that ran N platform scripts could surface only the long-running one that happened
|
||||
/// to get its IME result logged in time. Result is derived from the exit code (0 → Success,
|
||||
/// else Failed) and tagged <c>resultSource=agentexecutor_fallback</c> so the data is honest
|
||||
/// about its provenance.
|
||||
/// <para>
|
||||
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
|
||||
/// <paramref name="force"/> bypasses the grace check for the final pass on shutdown.
|
||||
/// </para>
|
||||
/// Platform scripts already emitted (via the authoritative PS-SCRIPT-RESULT path or the
|
||||
/// exit-code fallback), keyed by policy id with the source timestamp of the emitted run
|
||||
/// (the result line, or the exit line for a fallback emit). Guards against double emission
|
||||
/// when both paths fire for the same script, and tells a late start line of the emitted
|
||||
/// run from the start of the next run.
|
||||
/// </summary>
|
||||
internal void FlushStalePlatformScriptResults(DateTime nowUtc, bool force = false)
|
||||
private readonly Dictionary<string, DateTime> _platformScriptResultEmitted =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// End-of-pass emit for pending platform scripts, two cases:
|
||||
/// <list type="bullet">
|
||||
/// <item>A held IME result (<see cref="ScriptExecutionState.ResultHeldSinceUtc"/>) goes
|
||||
/// out once its executor end block has been read — the block arrives within one pass, so
|
||||
/// stdout/stderr are in by now — or after <see cref="PlatformScriptEndBlockGrace"/>.</item>
|
||||
/// <item>A script with a known AgentExecutor exit code but no IME <c>PS-SCRIPT-RESULT</c>
|
||||
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from the exit code
|
||||
/// (0 → Success, else Failed), tagged <c>resultSource=agentexecutor_fallback</c> so the
|
||||
/// data is honest about its provenance. Without this, scripts that completed shortly
|
||||
/// before the agent terminates were silently dropped.</item>
|
||||
/// </list>
|
||||
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
|
||||
/// <paramref name="force"/> bypasses both grace checks for the final pass on shutdown.
|
||||
/// </summary>
|
||||
internal void FlushPendingPlatformScriptResults(DateTime nowUtc, bool force = false)
|
||||
{
|
||||
if (_pendingPlatformScripts.Count == 0) return;
|
||||
|
||||
List<string> toRemove = null;
|
||||
foreach (var kv in _pendingPlatformScripts)
|
||||
var fallbackEmitted = false;
|
||||
foreach (var script in _pendingPlatformScripts.Values.ToList())
|
||||
{
|
||||
var script = kv.Value;
|
||||
|
||||
// Already emitted by the authoritative path — just drop the stale buffer entry.
|
||||
if (_platformScriptResultEmitted.Contains(script.PolicyId))
|
||||
if (_platformScriptResultEmitted.ContainsKey(script.PolicyId))
|
||||
{
|
||||
(toRemove ??= new List<string>()).Add(kv.Key);
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.Result != null)
|
||||
{
|
||||
if (script.ExitCode.HasValue)
|
||||
EmitPlatformScriptResult(script, "executor end block read after the result");
|
||||
else if (force || !script.ResultHeldSinceUtc.HasValue || nowUtc - script.ResultHeldSinceUtc.Value >= PlatformScriptEndBlockGrace)
|
||||
EmitPlatformScriptResult(script, $"no executor end block within {PlatformScriptEndBlockGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -560,30 +657,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
script.Result = script.ExitCode.Value == 0 ? "Success" : "Failed";
|
||||
script.ResultSource = "agentexecutor_fallback";
|
||||
|
||||
_logger.Info(
|
||||
$"ImeLogTracker: platform script {script.PolicyId} result not seen within " +
|
||||
$"{PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? " (shutdown flush)" : "")} — " +
|
||||
$"emitting from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
|
||||
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(script.PolicyId);
|
||||
(toRemove ??= new List<string>()).Add(kv.Key);
|
||||
EmitPlatformScriptResult(script,
|
||||
$"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)} — emitted from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
|
||||
fallbackEmitted = true;
|
||||
}
|
||||
|
||||
if (toRemove != null)
|
||||
{
|
||||
// The flush can fire on a quiet polling cycle (grace expiry with no new log
|
||||
// lines), where nothing else marks state dirty — the emitted-markers must reach
|
||||
// the state file before a restart or the restarted tracker re-emits (H1).
|
||||
_stateDirty = true;
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_pendingPlatformScripts.Remove(key);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, key, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
}
|
||||
}
|
||||
// The fallback is an end signal like the others: the result line it stood in for
|
||||
// may sit overwritten behind the bookmark of the other file.
|
||||
if (fallbackEmitted) RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
|
||||
@@ -602,49 +683,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
Stdout = stdout,
|
||||
StartedAtUtc = startedAtUtc,
|
||||
};
|
||||
_lastPlatformScriptPolicyId = policyId;
|
||||
NextTestEntry();
|
||||
RecordInvocationMarker(policyId, isClose: false);
|
||||
}
|
||||
|
||||
/// <summary>Test seam: simulate the authoritative IME PS-SCRIPT-RESULT path for a platform
|
||||
/// script (same code as <see cref="HandleScriptCompleted"/> minus regex extraction).
|
||||
/// <paramref name="resultLineTimestampUtc"/> stands in for the CMTrace timestamp of the
|
||||
/// PS-SCRIPT-RESULT line (production reads it via <see cref="LastMatchedLogTimestamp"/>).</summary>
|
||||
/// <summary>Test seam: the authoritative IME PS-SCRIPT-RESULT path for a platform script
|
||||
/// (the production handler minus regex extraction). <paramref name="resultLineTimestampUtc"/>
|
||||
/// stands in for the CMTrace timestamp of the PS-SCRIPT-RESULT line (production reads it
|
||||
/// via <see cref="LastMatchedLogTimestamp"/>, the fallback here).</summary>
|
||||
internal void CompletePlatformScriptFromImeResultForTesting(string policyId, string result, DateTime? resultLineTimestampUtc = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT");
|
||||
}
|
||||
|
||||
if (_platformScriptResultEmitted.Contains(policyId))
|
||||
{
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, policyId, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the AgentExecutor exit-code line of a pending platform script (the
|
||||
/// production handler minus regex extraction and positional ownership).</summary>
|
||||
internal void RecordPlatformScriptExitCodeForTesting(string policyId, int exitCode, DateTime observedAtUtc)
|
||||
{
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
{
|
||||
// Mirror of HandleScriptCompleted's stale-result guard: a result older than the
|
||||
// pending slot's start belongs to a previous run of the same policy.
|
||||
if (script.StartedAtUtc.HasValue
|
||||
&& resultLineTimestampUtc.HasValue
|
||||
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
script = new ScriptExecutionState { PolicyId = policyId, ScriptType = "platform" };
|
||||
}
|
||||
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(policyId);
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, policyId, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
_stateDirty = true;
|
||||
ApplyExitCode(script, exitCode, observedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -794,6 +852,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
HandleHealthScriptResultJson(json);
|
||||
// A remediation executor just ended too — its end block may sit behind the bookmark.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+254
-241
@@ -78,243 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) continue;
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
var hadPreviousObservation = _positionTracker.HasSeen(filePath);
|
||||
var lastCheckedUtc = _positionTracker.GetLastCheckedUtc(filePath);
|
||||
var passNowUtc = UtcNowProvider();
|
||||
|
||||
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
|
||||
|
||||
// Every look counts, including "no new data" and an empty first sight: the
|
||||
// NEXT pass's freshness window is measured from here. Restored bookmarks
|
||||
// deliberately carry no LastCheckedUtc — the first pass after a restart reads
|
||||
// downtime backlog and must never count as fresh.
|
||||
_positionTracker.MarkChecked(filePath, passNowUtc);
|
||||
|
||||
if (startPos >= fileInfo.Length)
|
||||
{
|
||||
// M2: guard the interpolated string so it isn't built every 100 ms tick
|
||||
// (per file) when Trace is off — which is the production default (Info).
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})");
|
||||
continue;
|
||||
}
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
|
||||
|
||||
_currentSourceFileName = Path.GetFileName(filePath);
|
||||
_currentPassLinesAreFresh = hadPreviousObservation
|
||||
&& lastCheckedUtc > DateTime.MinValue
|
||||
&& (passNowUtc - lastCheckedUtc) <= FreshLineMaxAge;
|
||||
|
||||
// Newest bias-less line of this pass — the calibration anchor. Bias-carrying
|
||||
// lines are skipped: they already state the writer's offset, so they need no
|
||||
// measurement and must not overwrite one.
|
||||
CmTraceLogEntry calibrationAnchor = null;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(startPos, SeekOrigin.Begin);
|
||||
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes);
|
||||
var stats = new PassStats();
|
||||
|
||||
// Buffer for multiline CMTrace entries (e.g. AgentExecutor.log
|
||||
// "write output done. output = ..." spans many lines)
|
||||
StringBuilder multiLineBuffer = null;
|
||||
int multiLineCount = 0;
|
||||
// Set after a capped entry is dropped: its remaining physical lines
|
||||
// are skipped instead of being matched as raw text, until the entry
|
||||
// closes or a new entry begins (so no real entry is lost).
|
||||
bool skippingDroppedEntry = false;
|
||||
// File offset where the entry currently being assembled/processed
|
||||
// begins — the bookmark to fall back to when that entry is NOT
|
||||
// consumed (cancellation, or a held-back unterminated tail).
|
||||
long entryStart = startPos;
|
||||
// Where the next pass resumes. EOF unless an entry is left unconsumed.
|
||||
long resumePos;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// CancellationToken.None: cancellation is honoured by the explicit
|
||||
// check below (exact bookmark) rather than by an exception out of
|
||||
// the read, which the poll loop would log as an error.
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None);
|
||||
if (line == null)
|
||||
{
|
||||
// EOF. An in-flight multiline entry here is an entry the writer
|
||||
// has not finished — hold it back (see HoldBackTail) instead of
|
||||
// dropping it and raw-matching its remaining lines next pass.
|
||||
if (multiLineBuffer != null && HoldBackTail(filePath, entryStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
_heldTailCount++;
|
||||
resumePos = entryStart;
|
||||
break;
|
||||
}
|
||||
resumePos = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
// Exact bookmark: nothing of this line (or of the multiline
|
||||
// entry it belongs to) has been handled.
|
||||
resumePos = multiLineBuffer != null ? entryStart : reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
if (multiLineBuffer == null)
|
||||
entryStart = reader.LastLineStart;
|
||||
|
||||
_linesRead++;
|
||||
|
||||
// A physical line over the cap is never matched: its captures would be
|
||||
// cut and the regexes would run over attacker-sized input. The capped
|
||||
// prefix is enough to tell whether it opened an entry whose remaining
|
||||
// lines must be skipped rather than raw-matched.
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
stats.OversizedLines++;
|
||||
_oversizedLines++;
|
||||
if (multiLineBuffer != null)
|
||||
{
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
skippingDroppedEntry = true;
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
skippingDroppedEntry = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!reader.LastLineTerminated && multiLineBuffer == null
|
||||
&& HoldBackTail(filePath, reader.LastLineStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
// Unterminated single line at EOF: the writer is mid-line.
|
||||
_heldTailCount++;
|
||||
resumePos = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
if (skippingDroppedEntry)
|
||||
{
|
||||
// A line opening a new entry ends the skip and is processed
|
||||
// itself — checked BEFORE the close-tag test, because a complete
|
||||
// single-line entry contains both and must not be consumed as
|
||||
// the dropped entry's close.
|
||||
if (line.StartsWith("<![LOG["))
|
||||
skippingDroppedEntry = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains("]LOG]!>")) skippingDroppedEntry = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multiline CMTrace buffering ---
|
||||
// CMTrace entries: <![LOG[message]LOG]!><time=...>
|
||||
// When message contains newlines, the entry spans multiple lines.
|
||||
// We buffer until we find the closing ]LOG]!> tag.
|
||||
if (multiLineBuffer != null)
|
||||
{
|
||||
// Continuing a multiline entry
|
||||
multiLineBuffer.Append('\n').Append(line);
|
||||
multiLineCount++;
|
||||
|
||||
if (line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Entry complete — use the assembled line
|
||||
line = multiLineBuffer.ToString();
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
}
|
||||
else if (multiLineCount >= MaxMultiLineBufferLines || multiLineBuffer.Length >= MaxEntryBytes)
|
||||
{
|
||||
// Safety limit — discard to bound memory and parser work. Warning
|
||||
// (not Debug) so a capped entry is visible in the client log at the
|
||||
// default level: a real IME entry this large would be news.
|
||||
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {Path.GetFileName(filePath)} after {multiLineCount} lines / {multiLineBuffer.Length} chars (cap {MaxMultiLineBufferLines} lines / {MaxEntryBytes} chars) — entry dropped");
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
skippingDroppedEntry = true;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still accumulating — read next line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Start of a multiline CMTrace entry
|
||||
multiLineBuffer = new StringBuilder(line);
|
||||
multiLineCount = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry entry;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entryStart;
|
||||
if (CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
messageToMatch = entry.Message;
|
||||
if (entry.HasTimestamp && !entry.BiasMinutes.HasValue)
|
||||
calibrationAnchor = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-CMTrace line - match raw
|
||||
messageToMatch = line;
|
||||
entry = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(messageToMatch)) continue;
|
||||
|
||||
// Simulation mode delay
|
||||
if (SimulationMode && entry != null)
|
||||
{
|
||||
await ApplySimulationDelay(ResolveEntryUtc(entry), token);
|
||||
}
|
||||
|
||||
MatchLine(filePath, line, messageToMatch, entry, stats);
|
||||
}
|
||||
|
||||
ClearHeldTailIfConsumed(filePath, resumePos);
|
||||
_positionTracker.SetPosition(filePath, resumePos);
|
||||
_stateDirty = true;
|
||||
backlogBytes += Math.Max(0, fileInfo.Length - resumePos);
|
||||
|
||||
// One line per pass and file, never per hostile line: the counters are
|
||||
// the operator's only trace that matching was skipped or cut short.
|
||||
if (stats.RegexTimeouts > 0 || stats.BudgetBreaks > 0 || stats.OversizedLines > 0)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ImeLogTracker: {Path.GetFileName(filePath)} pass skipped work — " +
|
||||
$"oversizedLines={stats.OversizedLines} (cap {MaxEntryBytes} bytes), " +
|
||||
$"regexTimeouts={stats.RegexTimeouts}, lineBudgetBreaks={stats.BudgetBreaks}" +
|
||||
(stats.FirstSkippedPatternId != null ? $", firstSkippedPattern={stats.FirstSkippedPatternId}" : string.Empty));
|
||||
RaiseTrackerDegradedOnce(Path.GetFileName(filePath), stats.FirstSkippedPatternId);
|
||||
}
|
||||
|
||||
// Calibrate AFTER the pass: this pass's lines were resolved with the
|
||||
// offset established previously, at most one poll (100 ms) old. Buffering
|
||||
// the pass to calibrate first is not an option — the first pass of
|
||||
// AppWorkload.log can be hundreds of MB. The cost is a warm-up of one
|
||||
// growing pass, during which lines fall back to the reader zone and are
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, Path.GetFileName(filePath), calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
}
|
||||
backlogBytes += await ProcessFileAsync(filePath, token);
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
catch (IOException ex)
|
||||
@@ -330,6 +94,246 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
|
||||
/// by the overwrite check that may move the bookmark back first (see
|
||||
/// <c>ImeLogTracker.Overwrite.cs</c>). Returns the file's remaining backlog in bytes.
|
||||
/// </summary>
|
||||
private async Task<long> ProcessFileAsync(string filePath, CancellationToken token)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) return 0;
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
var hadPreviousObservation = _positionTracker.HasSeen(filePath);
|
||||
var lastCheckedUtc = _positionTracker.GetLastCheckedUtc(filePath);
|
||||
var passNowUtc = UtcNowProvider();
|
||||
|
||||
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
|
||||
|
||||
// Every look counts, including "no new data" and an empty first sight: the
|
||||
// NEXT pass's freshness window is measured from here. Restored bookmarks
|
||||
// deliberately carry no LastCheckedUtc — the first pass after a restart reads
|
||||
// downtime backlog and must never count as fresh.
|
||||
_positionTracker.MarkChecked(filePath, passNowUtc);
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var ledger = IsMultiWriterLogFile(fileName) ? GetLedger(fileName) : null;
|
||||
|
||||
// Set by a rewind: entries that still read unchanged behind the old bookmark are
|
||||
// skipped, and nothing below the old bookmark is fresh or a calibration anchor.
|
||||
HashSet<LedgerEntry> rewoundEntries = null;
|
||||
long oldBookmark = -1;
|
||||
|
||||
if (ledger != null)
|
||||
{
|
||||
var rewindTo = await PrepareLedgerAsync(filePath, fileName, ledger, startPos, passNowUtc, token);
|
||||
if (rewindTo >= 0)
|
||||
{
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = rewindTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (startPos >= fileInfo.Length)
|
||||
{
|
||||
// M2: guard the interpolated string so it isn't built every 100 ms tick
|
||||
// (per file) when Trace is off — which is the production default (Info).
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: {fileName} — no new data (pos={startPos}, size={fileInfo.Length})");
|
||||
return 0;
|
||||
}
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: reading {fileName} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
|
||||
|
||||
_currentSourceFileName = fileName;
|
||||
var passLinesAreFresh = hadPreviousObservation
|
||||
&& lastCheckedUtc > DateTime.MinValue
|
||||
&& (passNowUtc - lastCheckedUtc) <= FreshLineMaxAge;
|
||||
_currentPassLinesAreFresh = passLinesAreFresh;
|
||||
|
||||
// Newest bias-less line of this pass — the calibration anchor. Bias-carrying
|
||||
// lines are skipped: they already state the writer's offset, so they need no
|
||||
// measurement and must not overwrite one.
|
||||
CmTraceLogEntry calibrationAnchor = null;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
// Guard zone: the file grew and is open anyway — re-check the entries just behind
|
||||
// the bookmark, where a stale writer's block lands when it is short.
|
||||
if (ledger != null && rewoundEntries == null && ledger.Entries.Count > 0)
|
||||
{
|
||||
var guardFrom = ledger.OffsetOfEntryAtOrAfter(startPos - OverwriteGuardBytes);
|
||||
if (guardFrom >= 0 && guardFrom < startPos)
|
||||
{
|
||||
var divergence = await FindOverwriteAsync(stream, ledger, guardFrom, startPos, token);
|
||||
_verifiedBytes += startPos - guardFrom;
|
||||
if (divergence >= 0)
|
||||
{
|
||||
// The guard saw only the tail of the ledger; the block may start earlier.
|
||||
var earliest = await FindOverwriteAsync(stream, ledger, ledger.StartOffset, startPos, token);
|
||||
_verifiedBytes += startPos - ledger.StartOffset;
|
||||
_verifyPasses++;
|
||||
if (earliest >= 0) divergence = earliest;
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = divergence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Seek(startPos, SeekOrigin.Begin);
|
||||
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: ledger != null);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
var stats = new PassStats();
|
||||
var firstEntry = true;
|
||||
// Where the next pass resumes. EOF unless an entry is left unconsumed.
|
||||
long resumePos;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// CancellationToken.None: cancellation is honoured by the explicit
|
||||
// check below (exact bookmark) rather than by an exception out of
|
||||
// the read, which the poll loop would log as an error.
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None);
|
||||
if (line == null)
|
||||
{
|
||||
// EOF. An in-flight multiline entry here is an entry the writer
|
||||
// has not finished — hold it back (see HoldBackTail) instead of
|
||||
// dropping it and raw-matching its remaining lines next pass.
|
||||
if (assembler.HasOpenEntry && HoldBackTail(filePath, assembler.OpenEntryStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
_heldTailCount++;
|
||||
resumePos = assembler.OpenEntryStart;
|
||||
break;
|
||||
}
|
||||
resumePos = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
// Exact bookmark: nothing of this line (or of the multiline
|
||||
// entry it belongs to) has been handled.
|
||||
resumePos = assembler.HasOpenEntry ? assembler.OpenEntryStart : reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
_linesRead++;
|
||||
|
||||
if (!reader.LastLineTruncated && !reader.LastLineTerminated && !assembler.HasOpenEntry
|
||||
&& HoldBackTail(filePath, reader.LastLineStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
// Unterminated single line at EOF: the writer is mid-line.
|
||||
_heldTailCount++;
|
||||
resumePos = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
AssembledEntry entry;
|
||||
var outcome = assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out entry);
|
||||
if (outcome == CmTraceEntryAssembler.Outcome.OversizedDropped)
|
||||
{
|
||||
// A physical line over the cap is never matched: its captures would be
|
||||
// cut and the regexes would run over attacker-sized input.
|
||||
stats.OversizedLines++;
|
||||
_oversizedLines++;
|
||||
continue;
|
||||
}
|
||||
if (outcome == CmTraceEntryAssembler.Outcome.CapDropped)
|
||||
{
|
||||
// Warning (not Debug) so a capped entry is visible in the client log at the
|
||||
// default level: a real IME entry this large would be news.
|
||||
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {fileName} after {assembler.DroppedLines} lines / {assembler.DroppedChars} chars (cap {MaxMultiLineBufferLines} lines / {MaxEntryBytes} chars) — entry dropped");
|
||||
continue;
|
||||
}
|
||||
if (outcome != CmTraceEntryAssembler.Outcome.Entry) continue;
|
||||
|
||||
if (firstEntry)
|
||||
{
|
||||
firstEntry = false;
|
||||
// A fragment right at the bookmark of a multi-writer file (the previous
|
||||
// entry ended exactly here, this one does not open an entry) is the tail of
|
||||
// a block a stale writer laid over already-read bytes: verify before reading on.
|
||||
if (ledger != null && rewoundEntries == null && entry.Offset == ledger.EndOffset
|
||||
&& ledger.LastFragmentOffset != entry.Offset && !entry.Text.StartsWith("<![LOG["))
|
||||
{
|
||||
ledger.VerifyRequested = true;
|
||||
ledger.LastFragmentOffset = entry.Offset;
|
||||
resumePos = entry.Offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ledger != null) ledger.Record(entry);
|
||||
if (rewoundEntries != null && rewoundEntries.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash }))
|
||||
continue; // unchanged behind the old bookmark — processed before the rewind
|
||||
|
||||
_currentPassLinesAreFresh = passLinesAreFresh && entry.Offset >= oldBookmark;
|
||||
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry parsed;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entry.Offset;
|
||||
if (CmTraceLogParser.TryParseLine(entry.Text, out parsed))
|
||||
{
|
||||
messageToMatch = parsed.Message;
|
||||
if (parsed.HasTimestamp && !parsed.BiasMinutes.HasValue && entry.Offset >= oldBookmark)
|
||||
calibrationAnchor = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-CMTrace line - match raw
|
||||
messageToMatch = entry.Text;
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(messageToMatch)) continue;
|
||||
|
||||
// Simulation mode delay
|
||||
if (SimulationMode && parsed != null)
|
||||
{
|
||||
await ApplySimulationDelay(ResolveEntryUtc(parsed), token);
|
||||
}
|
||||
|
||||
MatchLine(filePath, entry.Text, messageToMatch, parsed, stats);
|
||||
}
|
||||
|
||||
ClearHeldTailIfConsumed(filePath, resumePos);
|
||||
_positionTracker.SetPosition(filePath, resumePos);
|
||||
_stateDirty = true;
|
||||
if (ledger != null) ledger.TrimBelow(RetentionFloor(fileName, resumePos));
|
||||
|
||||
// One line per pass and file, never per hostile line: the counters are
|
||||
// the operator's only trace that matching was skipped or cut short.
|
||||
if (stats.RegexTimeouts > 0 || stats.BudgetBreaks > 0 || stats.OversizedLines > 0)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ImeLogTracker: {fileName} pass skipped work — " +
|
||||
$"oversizedLines={stats.OversizedLines} (cap {MaxEntryBytes} bytes), " +
|
||||
$"regexTimeouts={stats.RegexTimeouts}, lineBudgetBreaks={stats.BudgetBreaks}" +
|
||||
(stats.FirstSkippedPatternId != null ? $", firstSkippedPattern={stats.FirstSkippedPatternId}" : string.Empty));
|
||||
RaiseTrackerDegradedOnce(fileName, stats.FirstSkippedPatternId);
|
||||
}
|
||||
|
||||
// Calibrate AFTER the pass: this pass's lines were resolved with the
|
||||
// offset established previously, at most one poll (100 ms) old. Buffering
|
||||
// the pass to calibrate first is not an option — the first pass of
|
||||
// AppWorkload.log can be hundreds of MB. The cost is a warm-up of one
|
||||
// growing pass, during which lines fall back to the reader zone and are
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, fileName, calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
|
||||
return Math.Max(0, fileInfo.Length - resumePos);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PassStats
|
||||
{
|
||||
public int RegexTimeouts;
|
||||
@@ -828,10 +832,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
break;
|
||||
|
||||
case "resetplatformscriptcontext":
|
||||
// Session 6b4993e5 fix: a fresh AgentExecutor invocation banner
|
||||
// ("ExecutorLog AgentExecutor gets invoked") ends the previous
|
||||
// invocation's line-capture context. See HandleAgentExecutorInvocationBoundary.
|
||||
HandleAgentExecutorInvocationBoundary();
|
||||
// "ExecutorLog AgentExecutor gets invoked": a new process — the executor
|
||||
// lines that follow belong to its own argument line, never to the
|
||||
// previous invocation (session 6b4993e5).
|
||||
HandleAgentExecutorInvocation();
|
||||
break;
|
||||
|
||||
case "agentinvocationargument":
|
||||
HandleAgentInvocationArgument(match.Groups["kind"]?.Value);
|
||||
break;
|
||||
|
||||
case "agentexecutorcompleted":
|
||||
HandleAgentExecutorCompleted();
|
||||
break;
|
||||
|
||||
case "healthscriptresult":
|
||||
@@ -864,6 +876,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal void ProcessLogMessageForTest(string message, DateTime? sourceTimestampUtc = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
NextTestEntry();
|
||||
// The test seam hands in an already-resolved UTC instant, so it takes the same route a
|
||||
// writer-declared bias does: TimestampUtc set, no zone left to guess.
|
||||
var entry = sourceTimestampUtc.HasValue
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: overwrite-aware tailing of the two IME logs that several processes append to.
|
||||
/// <para>
|
||||
/// IME's trace listener opens each log once per process with FileMode.Append and
|
||||
/// FileShare.ReadWrite and writes at its own stream position afterwards. AgentExecutor.exe
|
||||
/// (two processes per user-context script, one per detection or remediation script) has such
|
||||
/// a listener on AgentExecutor.log AND on IntuneManagementExtension.log. Whenever one process
|
||||
/// resumes writing after another one appended, it overwrites those bytes: the final file
|
||||
/// holds the last writer, while a tailer that trusts its bookmark read the first version and
|
||||
/// never sees the second (session 46749560: 6 of 16 script results, 4 of 16 exit blocks).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the bytes behind the bookmark are not immutable. The tracker keeps a fingerprint ledger
|
||||
/// of every entry it read from these files, re-verifies it while script invocations are in
|
||||
/// flight or right after a signal that a stale writer just wrote, and on the first changed
|
||||
/// entry rewinds the bookmark — processing only entries it has not processed before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
/// <summary>Live logs with more than one writing process; archived rotations are closed files.</summary>
|
||||
internal static readonly string[] MultiWriterLogFiles = { "AgentExecutor.log", "IntuneManagementExtension.log" };
|
||||
|
||||
/// <summary>Ledger depth behind the bookmark while no script is in flight.</summary>
|
||||
internal const int LedgerRetentionBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Hard cap on the ledger depth, whatever is in flight.</summary>
|
||||
internal const int LedgerMaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Entries this close to the bookmark are re-checked on every pass that found growth (the file is open anyway).</summary>
|
||||
internal const int OverwriteGuardBytes = 4 * 1024;
|
||||
|
||||
/// <summary>After a restart the ledger is seeded from this many bytes before the persisted bookmark.</summary>
|
||||
internal const int RestartSeedBytes = 64 * 1024;
|
||||
|
||||
/// <summary>Verification work per second while in contention — a deep ledger is checked less often, never skipped.</summary>
|
||||
internal const long VerifyBudgetBytesPerSecond = 256 * 1024;
|
||||
|
||||
internal static readonly TimeSpan VerifyMinInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>How long an observed invocation keeps the files in contention on its own (detection executors have no pending slot).</summary>
|
||||
internal static readonly TimeSpan InvocationContentionWindow = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>A pending script older than this no longer holds the files in contention.</summary>
|
||||
internal static readonly TimeSpan MaxPendingScriptContention = TimeSpan.FromHours(2);
|
||||
|
||||
private const int MaxInvocationMarkersPerFile = 512;
|
||||
private const string TestSourceFileName = "TEST.log";
|
||||
private const string LogOpenTag = "<![LOG[";
|
||||
|
||||
/// <summary>One processed entry: where it starts, how far it reaches, what its bytes hashed to.</summary>
|
||||
internal struct LedgerEntry : IEquatable<LedgerEntry>
|
||||
{
|
||||
public long Offset;
|
||||
public int Length;
|
||||
public ulong Hash;
|
||||
|
||||
// Identity is (offset, hash): a tail first read unterminated and later completed
|
||||
// keeps its hash but grows by its terminator.
|
||||
public bool Equals(LedgerEntry other) => Offset == other.Offset && Hash == other.Hash;
|
||||
public override bool Equals(object obj) => obj is LedgerEntry other && Equals(other);
|
||||
public override int GetHashCode() => unchecked(((int)Offset * 397) ^ (int)Hash ^ (int)(Hash >> 32));
|
||||
}
|
||||
|
||||
internal sealed class FileLedger
|
||||
{
|
||||
public readonly List<LedgerEntry> Entries = new List<LedgerEntry>();
|
||||
public bool VerifyRequested;
|
||||
public DateTime NextCadenceVerifyUtc = DateTime.MinValue;
|
||||
public long LastFragmentOffset = -1;
|
||||
public bool Seeded;
|
||||
public bool RewindLogged;
|
||||
|
||||
public long StartOffset => Entries.Count > 0 ? Entries[0].Offset : -1;
|
||||
public long EndOffset => Entries.Count > 0 ? Entries[Entries.Count - 1].Offset + Entries[Entries.Count - 1].Length : -1;
|
||||
|
||||
public int IndexOfFirstAtOrAfter(long offset)
|
||||
{
|
||||
int lo = 0, hi = Entries.Count;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = (lo + hi) >> 1;
|
||||
if (Entries[mid].Offset < offset) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/// <summary>Entry offset at or after <paramref name="offset"/>, or -1.</summary>
|
||||
public long OffsetOfEntryAtOrAfter(long offset)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(offset);
|
||||
return idx < Entries.Count ? Entries[idx].Offset : -1;
|
||||
}
|
||||
|
||||
public void Record(AssembledEntry e)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(e.Offset);
|
||||
if (idx < Entries.Count) Entries.RemoveRange(idx, Entries.Count - idx);
|
||||
Entries.Add(new LedgerEntry { Offset = e.Offset, Length = e.Length, Hash = e.Hash });
|
||||
}
|
||||
|
||||
/// <summary>Drops everything from <paramref name="offset"/> on and returns it — the entries a rewind must not process twice.</summary>
|
||||
public HashSet<LedgerEntry> TruncateFrom(long offset)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(offset);
|
||||
var removed = new HashSet<LedgerEntry>();
|
||||
for (var i = idx; i < Entries.Count; i++) removed.Add(Entries[i]);
|
||||
if (idx < Entries.Count) Entries.RemoveRange(idx, Entries.Count - idx);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void TrimBelow(long floor)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(floor);
|
||||
if (idx > 0) Entries.RemoveRange(0, idx);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Entries.Clear();
|
||||
LastFragmentOffset = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by file name (unique within the log folder). Poll thread only.
|
||||
private readonly Dictionary<string, FileLedger> _ledgers = new Dictionary<string, FileLedger>(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _contentionUntilUtc = DateTime.MinValue;
|
||||
|
||||
// Cumulative, restart-safe like the other health counters.
|
||||
private long _overwriteRewinds;
|
||||
private long _overwriteBytesReprocessed;
|
||||
private long _verifyPasses;
|
||||
private long _verifiedBytes;
|
||||
|
||||
internal static bool IsMultiWriterLogFile(string fileName)
|
||||
{
|
||||
foreach (var f in MultiWriterLogFiles)
|
||||
if (string.Equals(f, fileName, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private FileLedger GetLedger(string fileName)
|
||||
{
|
||||
FileLedger ledger;
|
||||
if (!_ledgers.TryGetValue(fileName, out ledger))
|
||||
{
|
||||
ledger = new FileLedger();
|
||||
_ledgers[fileName] = ledger;
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/// <summary>A stale writer writes exactly when its script ends: every end signal asks both files for a check on the next pass.</summary>
|
||||
private void RequestOverwriteCheck()
|
||||
{
|
||||
foreach (var ledger in _ledgers.Values) ledger.VerifyRequested = true;
|
||||
}
|
||||
|
||||
private void NoteInvocationActivity()
|
||||
{
|
||||
_contentionUntilUtc = UtcNowProvider() + InvocationContentionWindow;
|
||||
}
|
||||
|
||||
private bool InContention(DateTime nowUtc)
|
||||
{
|
||||
if (nowUtc < _contentionUntilUtc || _pendingHealthScript != null) return true;
|
||||
foreach (var script in _pendingPlatformScripts.Values)
|
||||
{
|
||||
if (script.Result != null) continue;
|
||||
if (!script.StartedAtUtc.HasValue || nowUtc - script.StartedAtUtc.Value < MaxPendingScriptContention) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs before a multi-writer file is read: rollover reset, restart seeding, then the
|
||||
/// overwrite check when one is due. Returns the offset to rewind to, or -1.
|
||||
/// </summary>
|
||||
private async Task<long> PrepareLedgerAsync(string filePath, string fileName, FileLedger ledger, long bookmark, DateTime nowUtc, CancellationToken token)
|
||||
{
|
||||
if (ledger.Entries.Count > 0 && bookmark < ledger.EndOffset)
|
||||
{
|
||||
// The bookmark moved below what was read: the file was replaced (rollover).
|
||||
ledger.Clear();
|
||||
InvalidateInvocationMarkersFrom(fileName, 0);
|
||||
}
|
||||
|
||||
if (!ledger.Seeded)
|
||||
{
|
||||
ledger.Seeded = true;
|
||||
if (ledger.Entries.Count == 0 && bookmark > 0)
|
||||
await SeedLedgerAsync(filePath, ledger, bookmark, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var due = ledger.VerifyRequested || (InContention(nowUtc) && nowUtc >= ledger.NextCadenceVerifyUtc);
|
||||
if (!due || ledger.Entries.Count == 0) return -1;
|
||||
|
||||
var from = ledger.StartOffset;
|
||||
var bytes = bookmark - from;
|
||||
if (bytes <= 0)
|
||||
{
|
||||
ledger.VerifyRequested = false;
|
||||
return -1;
|
||||
}
|
||||
|
||||
long divergence;
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
divergence = await FindOverwriteAsync(stream, ledger, from, bookmark, token).ConfigureAwait(false);
|
||||
if (token.IsCancellationRequested) return -1;
|
||||
|
||||
ledger.VerifyRequested = false;
|
||||
var paceSeconds = Math.Max(VerifyMinInterval.TotalSeconds, (double)bytes / VerifyBudgetBytesPerSecond);
|
||||
ledger.NextCadenceVerifyUtc = nowUtc.AddSeconds(paceSeconds);
|
||||
_verifyPasses++;
|
||||
_verifiedBytes += bytes;
|
||||
return divergence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads [fromOffset, toOffset) with the read loop's assembler and compares against the
|
||||
/// ledger. Returns the offset of the first entry whose bytes changed — a ledger entry not
|
||||
/// reproduced, or an entry the ledger never had — or -1 when everything reads as before.
|
||||
/// </summary>
|
||||
private async Task<long> FindOverwriteAsync(FileStream stream, FileLedger ledger, long fromOffset, long toOffset, CancellationToken token)
|
||||
{
|
||||
var entries = ledger.Entries;
|
||||
var idx = ledger.IndexOfFirstAtOrAfter(fromOffset);
|
||||
if (idx >= entries.Count) return -1;
|
||||
|
||||
stream.Seek(entries[idx].Offset, SeekOrigin.Begin);
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: true);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
|
||||
while (reader.Position < toOffset)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (line == null) break;
|
||||
if (token.IsCancellationRequested) return -1;
|
||||
|
||||
AssembledEntry e;
|
||||
if (assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out e)
|
||||
!= CmTraceEntryAssembler.Outcome.Entry)
|
||||
continue;
|
||||
if (e.Offset >= toOffset) break;
|
||||
|
||||
if (idx < entries.Count && entries[idx].Offset < e.Offset) return entries[idx].Offset;
|
||||
if (idx < entries.Count && entries[idx].Offset == e.Offset)
|
||||
{
|
||||
if (entries[idx].Hash != e.Hash) return e.Offset;
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
return e.Offset; // an entry the ledger never had: the bytes changed here
|
||||
}
|
||||
|
||||
return idx < entries.Count && entries[idx].Offset < toOffset ? entries[idx].Offset : -1;
|
||||
}
|
||||
|
||||
/// <summary>After a restart the ledger is empty: fingerprint the entries just behind the persisted bookmark without processing them.</summary>
|
||||
private async Task SeedLedgerAsync(string filePath, FileLedger ledger, long bookmark, CancellationToken token)
|
||||
{
|
||||
var from = Math.Max(0, bookmark - RestartSeedBytes);
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(from, SeekOrigin.Begin);
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: true);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
var aligned = from == 0;
|
||||
while (reader.Position < bookmark)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (line == null || token.IsCancellationRequested) break;
|
||||
if (!aligned)
|
||||
{
|
||||
// The seek may have landed mid-entry: wait for an entry boundary.
|
||||
if (!line.StartsWith(LogOpenTag)) continue;
|
||||
aligned = true;
|
||||
}
|
||||
AssembledEntry e;
|
||||
if (assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out e)
|
||||
== CmTraceEntryAssembler.Outcome.Entry
|
||||
&& e.Offset + e.Length <= bookmark)
|
||||
ledger.Record(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves the bookmark back to the first changed entry; returns the entries to skip if they still read unchanged behind it.</summary>
|
||||
private HashSet<LedgerEntry> ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
|
||||
{
|
||||
var removed = ledger.TruncateFrom(rewindTo);
|
||||
_positionTracker.SetPosition(filePath, rewindTo);
|
||||
_heldTails.Remove(filePath);
|
||||
InvalidateInvocationMarkersFrom(fileName, rewindTo);
|
||||
_overwriteRewinds++;
|
||||
_overwriteBytesReprocessed += bookmark - rewindTo;
|
||||
|
||||
var message = $"ImeLogTracker: {fileName} changed behind the bookmark at offset {rewindTo} " +
|
||||
$"(a concurrent writer overwrote already-read bytes) — rewinding {bookmark - rewindTo} bytes";
|
||||
if (!ledger.RewindLogged)
|
||||
{
|
||||
ledger.RewindLogged = true;
|
||||
_logger.Info(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug(message);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private long RetentionFloor(string fileName, long bookmark)
|
||||
{
|
||||
var floor = bookmark - LedgerRetentionBytes;
|
||||
var oldest = OldestPendingScriptMarkerOffset(fileName);
|
||||
if (oldest >= 0 && oldest < floor) floor = oldest;
|
||||
var hardFloor = bookmark - LedgerMaxBytes;
|
||||
return floor < hardFloor ? hardFloor : floor;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invocation markers: who owns the policyId-less executor lines at a file position
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// AgentExecutor.log carries "Powershell exit code is N" / "write output done" without a
|
||||
// policy id. Their owner is decided by FILE POSITION, not by the order lines were read:
|
||||
// an executor writes its end block at its own stream position, i.e. directly after its
|
||||
// own start block in the final bytes, so the nearest preceding invocation marker names
|
||||
// it — and stays right after a rewind re-reads that region. A marker is a platform start
|
||||
// (policy id), some other invocation (detection, remediation, requirement: null) or a
|
||||
// close ("gets invoked" banner, "Agent executor completed").
|
||||
|
||||
private sealed class InvocationMarker
|
||||
{
|
||||
public long Offset;
|
||||
public string PolicyId;
|
||||
public bool IsClose;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, List<InvocationMarker>> _invocationMarkers =
|
||||
new Dictionary<string, List<InvocationMarker>>(StringComparer.OrdinalIgnoreCase);
|
||||
private InvocationMarker _lastPlatformMarker;
|
||||
private long _testEntryOrdinal;
|
||||
|
||||
private void RecordInvocationMarker(string policyId, bool isClose)
|
||||
{
|
||||
if (_currentEntryOffset < 0) return;
|
||||
var file = _currentSourceFileName ?? TestSourceFileName;
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(file, out list))
|
||||
{
|
||||
list = new List<InvocationMarker>();
|
||||
_invocationMarkers[file] = list;
|
||||
}
|
||||
|
||||
if (list.Count > 0 && list[list.Count - 1].Offset == _currentEntryOffset)
|
||||
{
|
||||
// Two patterns on one line: the platform start outranks the generic argument line.
|
||||
var last = list[list.Count - 1];
|
||||
if (!isClose && policyId != null)
|
||||
{
|
||||
last.PolicyId = policyId;
|
||||
last.IsClose = false;
|
||||
_lastPlatformMarker = last;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
|
||||
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, IsClose = isClose };
|
||||
list.Add(marker);
|
||||
if (!isClose && policyId != null) _lastPlatformMarker = marker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pending platform script that owns the entry being processed, or null.
|
||||
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
|
||||
/// gone (completion already emitted); null when no platform invocation owns the entry.
|
||||
/// </summary>
|
||||
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
InvocationMarker marker = null;
|
||||
List<InvocationMarker> list = null;
|
||||
if (_currentSourceFileName != null && _currentEntryOffset >= 0 && _invocationMarkers.TryGetValue(_currentSourceFileName, out list))
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (list[i].Offset <= _currentEntryOffset)
|
||||
{
|
||||
marker = list[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file without any marker (a context line after a rotation): the last platform start wins.
|
||||
if (marker == null && (list == null || list.Count == 0)) marker = _lastPlatformMarker;
|
||||
if (marker == null || marker.IsClose || marker.PolicyId == null) return null;
|
||||
|
||||
ownerPolicyId = marker.PolicyId;
|
||||
ScriptExecutionState state;
|
||||
return _pendingPlatformScripts.TryGetValue(marker.PolicyId, out state) ? state : null;
|
||||
}
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset)
|
||||
{
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return;
|
||||
var removedLast = false;
|
||||
for (var i = list.Count - 1; i >= 0 && list[i].Offset >= offset; i--)
|
||||
{
|
||||
if (ReferenceEquals(list[i], _lastPlatformMarker)) removedLast = true;
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
if (removedLast)
|
||||
{
|
||||
_lastPlatformMarker = null;
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!list[i].IsClose && list[i].PolicyId != null)
|
||||
{
|
||||
_lastPlatformMarker = list[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long OldestPendingScriptMarkerOffset(string fileName)
|
||||
{
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list)) return -1;
|
||||
long oldest = -1;
|
||||
foreach (var marker in list)
|
||||
{
|
||||
if (marker.IsClose || marker.PolicyId == null || !_pendingPlatformScripts.ContainsKey(marker.PolicyId)) continue;
|
||||
if (oldest < 0 || marker.Offset < oldest) oldest = marker.Offset;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test seams
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Same effect as an end signal: both multi-writer ledgers are verified on the next pass.</summary>
|
||||
internal void RequestOverwriteCheckForTest() => RequestOverwriteCheck();
|
||||
|
||||
internal int LedgerEntryCountForTest(string fileName)
|
||||
{
|
||||
FileLedger ledger;
|
||||
return _ledgers.TryGetValue(fileName, out ledger) ? ledger.Entries.Count : 0;
|
||||
}
|
||||
|
||||
/// <summary>Gives a seam-fed message a file position of its own so positional attribution behaves like in a real pass.</summary>
|
||||
internal void NextTestEntry()
|
||||
{
|
||||
if (_currentSourceFileName == null) _currentSourceFileName = TestSourceFileName;
|
||||
_currentEntryOffset = ++_testEntryOrdinal * 4096;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-12
@@ -181,7 +181,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// arrive across multiple log entries and IME may interleave platform scripts).
|
||||
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastPlatformScriptPolicyId;
|
||||
|
||||
// Health-script (remediation) line-by-line accumulator — single slot because IME
|
||||
// executes health scripts SEQUENTIALLY within a session (verified across multiple
|
||||
@@ -367,6 +366,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
FilesTailed = _filesTailed,
|
||||
BacklogBytes = _backlogBytes,
|
||||
ImeAgentVersion = _imeAgentVersionSeen,
|
||||
OverwriteRewinds = _overwriteRewinds,
|
||||
OverwriteBytesReprocessed = _overwriteBytesReprocessed,
|
||||
VerifyPasses = _verifyPasses,
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
}
|
||||
@@ -953,11 +956,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
await CheckLogFilesAsync(token);
|
||||
|
||||
// Safety net: emit completions for platform scripts whose AgentExecutor
|
||||
// exit code we already have but whose authoritative IME PS-SCRIPT-RESULT
|
||||
// line never arrived within the grace period. Runs on this same loop
|
||||
// Platform-script completions that wait for the other file: an IME result
|
||||
// held for its executor end block, and the exit-code fallback for a
|
||||
// result IME never logged within the grace period. Runs on this same loop
|
||||
// thread (no locking) so it observes the buffer right after parsing.
|
||||
FlushStalePlatformScriptResults(DateTime.UtcNow);
|
||||
FlushPendingPlatformScriptResults(UtcNowProvider());
|
||||
|
||||
// Token-failure grace window: fires only when the failure stayed
|
||||
// unresolved (no IME-TOKEN-SUCCESS) for the whole window. Runs after
|
||||
@@ -985,10 +988,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
// Final pass: force-flush any platform script that completed within the last grace
|
||||
// window before shutdown (exit code known, IME result still pending). Best-effort —
|
||||
// emits only while callbacks are still wired (i.e. the adapter hasn't been disposed).
|
||||
try { FlushStalePlatformScriptResults(DateTime.UtcNow, force: true); }
|
||||
// Final pass: force-flush any platform script still waiting for the other file
|
||||
// (a held IME result, or an exit code whose IME result is still pending).
|
||||
// Best-effort — emits only while callbacks are still wired (i.e. the adapter
|
||||
// hasn't been disposed).
|
||||
try { FlushPendingPlatformScriptResults(UtcNowProvider(), force: true); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: shutdown script flush failed: {ex.Message}"); }
|
||||
|
||||
// Final state save on shutdown
|
||||
@@ -1100,10 +1104,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// (the in-memory marker set started empty). Old state files have null here —
|
||||
// degrades to the pre-fix behavior, no crash.
|
||||
_platformScriptResultEmitted.Clear();
|
||||
if (state.PlatformScriptResultEmitted != null)
|
||||
if (state.PlatformScriptResultEmittedAt != null)
|
||||
{
|
||||
foreach (var kv in state.PlatformScriptResultEmittedAt)
|
||||
_platformScriptResultEmitted[kv.Key] = kv.Value;
|
||||
}
|
||||
else if (state.PlatformScriptResultEmitted != null)
|
||||
{
|
||||
// State files from before the marker carried the run's timestamp: the dedup
|
||||
// works as before, the late-start-line guard never fires for these runs.
|
||||
foreach (var id in state.PlatformScriptResultEmitted)
|
||||
_platformScriptResultEmitted.Add(id);
|
||||
_platformScriptResultEmitted[id] = DateTime.MinValue;
|
||||
}
|
||||
|
||||
_pendingPlatformScripts.Clear();
|
||||
@@ -1140,6 +1151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HealthScriptResultParseFailures = state.Health.HealthScriptResultParseFailures;
|
||||
_imeAgentVersionSeen = state.Health.ImeAgentVersion;
|
||||
_trackerDegradedFired = state.Health.TrackerDegradedFired;
|
||||
_overwriteRewinds = state.Health.OverwriteRewinds;
|
||||
_overwriteBytesReprocessed = state.Health.OverwriteBytesReprocessed;
|
||||
_verifyPasses = state.Health.VerifyPasses;
|
||||
_verifiedBytes = state.Health.VerifiedBytes;
|
||||
}
|
||||
if (state.PatternHits != null)
|
||||
{
|
||||
@@ -1190,7 +1205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// H1 (delta review 2026-07-02): platform-script dedup markers + pending buffer
|
||||
// must survive restarts — see the LoadState comment for the duplicate scenario.
|
||||
PlatformScriptResultEmitted = _platformScriptResultEmitted.ToList(),
|
||||
PlatformScriptResultEmittedAt = new Dictionary<string, DateTime>(_platformScriptResultEmitted, StringComparer.OrdinalIgnoreCase),
|
||||
PendingPlatformScripts = _pendingPlatformScripts.Values.ToList(),
|
||||
ScriptTimeoutSuspectedPosted = _scriptTimeoutSuspectedPosted.ToList(),
|
||||
};
|
||||
@@ -1209,6 +1224,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
|
||||
ImeAgentVersion = _imeAgentVersionSeen,
|
||||
TrackerDegradedFired = _trackerDegradedFired,
|
||||
OverwriteRewinds = _overwriteRewinds,
|
||||
OverwriteBytesReprocessed = _overwriteBytesReprocessed,
|
||||
VerifyPasses = _verifyPasses,
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
};
|
||||
state.PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
+17
@@ -46,6 +46,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>IME version as logged by IME itself ("Agent version is: …"), null until seen.</summary>
|
||||
public string ImeAgentVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Times the bookmark of a multi-writer log (AgentExecutor.log, IntuneManagementExtension.log)
|
||||
/// was moved back because bytes behind it had been overwritten by a concurrent IME
|
||||
/// process. Expected nonzero whenever user-context scripts or detection scripts ran —
|
||||
/// recovered data, not skipped work.
|
||||
/// </summary>
|
||||
public long OverwriteRewinds { get; set; }
|
||||
|
||||
/// <summary>Bytes re-read by those rewinds (only entries that changed are processed again).</summary>
|
||||
public long OverwriteBytesReprocessed { get; set; }
|
||||
|
||||
/// <summary>Full ledger verifications run (cadence while scripts are in flight, end signals, fragments).</summary>
|
||||
public long VerifyPasses { get; set; }
|
||||
|
||||
/// <summary>Bytes re-read for verification, guard-zone checks included — the cost of the overwrite detection.</summary>
|
||||
public long VerifiedBytes { get; set; }
|
||||
|
||||
/// <summary>Match count per enabled pattern ID — every enabled pattern is present, zeros included.</summary>
|
||||
public IReadOnlyDictionary<string, int> PatternHits { get; set; }
|
||||
|
||||
|
||||
+12
@@ -142,7 +142,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// continues its grace window after the restart instead of being silently dropped.
|
||||
// ScriptExecutionState is a plain DTO, serialized directly (no mapping layer needed).
|
||||
// All three are null on state files from before this fix — LoadState treats null as empty.
|
||||
// Read-only migration input: state files written before the marker carried the run's
|
||||
// source timestamp (agents up to 2.0.1458). Never written any more.
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public List<string> PlatformScriptResultEmitted { get; set; }
|
||||
// Policy id → source timestamp of the emitted run (its result line; the exit line for a
|
||||
// fallback emit): the dedup marker plus the reference that tells a late start line of
|
||||
// that run from the start of the next one.
|
||||
public Dictionary<string, DateTime> PlatformScriptResultEmittedAt { get; set; }
|
||||
public List<ScriptExecutionState> PendingPlatformScripts { get; set; }
|
||||
public List<string> ScriptTimeoutSuspectedPosted { get; set; }
|
||||
|
||||
@@ -165,6 +172,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public int HealthScriptResultParseFailures { get; set; }
|
||||
public string ImeAgentVersion { get; set; }
|
||||
public bool TrackerDegradedFired { get; set; }
|
||||
// Overwrite detection counters (null-defaulting on older state files).
|
||||
public long OverwriteRewinds { get; set; }
|
||||
public long OverwriteBytesReprocessed { get; set; }
|
||||
public long VerifyPasses { get; set; }
|
||||
public long VerifiedBytes { get; set; }
|
||||
}
|
||||
|
||||
public class PackageStateData
|
||||
|
||||
+24
-1
@@ -61,11 +61,34 @@ public class ScriptExecutionState
|
||||
/// <summary>
|
||||
/// UTC timestamp at which the AgentExecutor.log exit-code line for this platform script was
|
||||
/// observed. Drives the deadline check in
|
||||
/// <see cref="ImeLogTracker.FlushStalePlatformScriptResults"/> — set only on the platform path
|
||||
/// <see cref="ImeLogTracker.FlushPendingPlatformScriptResults"/> — set only on the platform path
|
||||
/// (PS-AGENT exit code); null until an exit code is seen.
|
||||
/// </summary>
|
||||
public System.DateTime? ExitObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source (CMTrace) timestamp of the IME <c>PS-SCRIPT-RESULT</c> line that delivered
|
||||
/// <see cref="Result"/> (platform scripts, <c>ime_policy_result</c> only). The emitted event is
|
||||
/// bound to it — the emit may happen passes later (see <see cref="ResultHeldSinceUtc"/>), when
|
||||
/// the tracker's "last matched" line is an unrelated one. Also the reference a later start line
|
||||
/// of the same policy is compared against: a start older than the emitted result is the late
|
||||
/// start line of that run, not a new run.
|
||||
/// </summary>
|
||||
public System.DateTime? ResultObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Pattern id of the line that delivered <see cref="Result"/> (surfaced as <c>patternId</c> on the event).</summary>
|
||||
public string ResultPatternId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock time (agent UTC) at which the IME result was seen while the executor end block
|
||||
/// (exit code, stdout, stderr) had not been read yet. The tracker holds the emit until the end
|
||||
/// block arrives — an overwritten block surfaces one pass later through the ledger rewind that
|
||||
/// the result itself triggers, and the AgentExecutor.log read can simply lag behind the IME log
|
||||
/// read — or until <c>ImeLogTracker.PlatformScriptEndBlockGrace</c> elapsed. Null when the
|
||||
/// result was not held.
|
||||
/// </summary>
|
||||
public System.DateTime? ResultHeldSinceUtc { get; set; }
|
||||
|
||||
/// <summary>"True" or "False" compliance result (remediation detection / post-detection only).</summary>
|
||||
public string ComplianceResult { get; set; }
|
||||
|
||||
|
||||
+4
@@ -209,6 +209,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["ime_line_budget_breaks"] = health.BudgetBreaks;
|
||||
data["ime_held_tails"] = health.HeldTails;
|
||||
data["ime_unanchored_patterns"] = health.UnanchoredPatterns;
|
||||
data["ime_overwrite_rewinds"] = health.OverwriteRewinds;
|
||||
data["ime_overwrite_bytes_reprocessed"] = health.OverwriteBytesReprocessed;
|
||||
data["ime_verify_passes"] = health.VerifyPasses;
|
||||
data["ime_verified_bytes"] = health.VerifiedBytes;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -9,9 +9,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <para>
|
||||
/// The orchestrator consumes this event and translates it into a synthetic
|
||||
/// <c>DeadlineFired</c>-<see cref="AutopilotMonitor.DecisionCore.Signals.DecisionSignal"/>
|
||||
/// whose <c>OccurredAtUtc</c> equals the deadline's <see cref="ActiveDeadline.DueAtUtc"/>,
|
||||
/// not the wall-clock firing time — so replay stays deterministic even if the agent
|
||||
/// restarts long after the deadline has passed.
|
||||
/// whose <c>OccurredAtUtc</c> equals the deadline's <see cref="ActiveDeadline.DueAtUtc"/>
|
||||
/// (the policy instant the wait expired), with the due time repeated in the payload. The
|
||||
/// firing clock is deliberately NOT the stamp: a timer due during Modern Standby or a
|
||||
/// reboot fires only afterwards, and the outage would otherwise become session duration
|
||||
/// (D-238).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DeadlineFiredEventArgs : EventArgs
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -1159,7 +1160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: e.Deadline.Name,
|
||||
summary: $"deadline '{e.Deadline.Name}' fired at {e.Deadline.DueAtUtc:O}");
|
||||
summary: $"deadline '{e.Deadline.Name}' due {e.Deadline.DueAtUtc:O} fired at {e.FiredAtUtc:O}");
|
||||
|
||||
// The reducer reads the deadline name under SignalPayloadKeys.Deadline
|
||||
// (see HandleDeadlineFiredV1 in DecisionEngine.Shared). Forward the
|
||||
@@ -1180,7 +1181,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
payload[SignalPayloadKeys.Deadline] = e.Deadline.Name;
|
||||
}
|
||||
|
||||
// OccurredAtUtc = DueAtUtc (not firedAt) — replay-determinism per DeadlineFiredEventArgs doc.
|
||||
// OccurredAtUtc = DueAtUtc, not the firing clock (D-238). Every effect of the
|
||||
// resulting step (phase_transition, enrollment_complete) is stamped with the signal
|
||||
// time; a timer that came due during Modern Standby or a reboot fires only afterwards,
|
||||
// and the firing clock would make the verdict — and the session duration — grow by
|
||||
// the whole outage (measured: days for devices closed right after provisioning).
|
||||
// The due time also travels in the payload so the stale-fire guards identify the
|
||||
// deadline incarnation independently of the stamp.
|
||||
payload[SignalPayloadKeys.DeadlineDueAtUtc] = e.Deadline.DueAtUtc.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.DeadlineFired,
|
||||
occurredAtUtc: e.Deadline.DueAtUtc,
|
||||
|
||||
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>
|
||||
/// Wird vom Timer-Thread geraised, wenn ein Deadline fällig wird. Subscriber
|
||||
/// ist typischerweise der Orchestrator, der daraus ein synthetisches
|
||||
/// <c>DeadlineFired</c>-Signal mit <c>OccurredAtUtc = deadline.DueAtUtc</c> erzeugt.
|
||||
/// <c>DeadlineFired</c>-Signal mit <c>OccurredAtUtc = deadline.DueAtUtc</c> erzeugt; die
|
||||
/// Fälligkeit reist zusätzlich im Payload (<c>SignalPayloadKeys.DeadlineDueAtUtc</c>).
|
||||
/// </summary>
|
||||
event EventHandler<DeadlineFiredEventArgs>? Fired;
|
||||
|
||||
|
||||
@@ -1254,23 +1254,25 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(script.Stdout)) data["stdout"] = script.Stdout!;
|
||||
if (!string.IsNullOrEmpty(script.Stderr)) data["stderr"] = script.Stderr!;
|
||||
|
||||
// The exit-code fallback emits asynchronously to log parsing, so the tracker's
|
||||
// "last matched" pattern/timestamp belong to an unrelated later line. Bind the event to
|
||||
// the script's own exit-observed timestamp and omit the misleading patternId instead.
|
||||
// A platform-script completion is bound to the script's own line, not to the tracker's
|
||||
// "last matched" one: the exit-code fallback emits a grace period after its exit line,
|
||||
// and an IME result held for its executor end block emits at the end of a later pass —
|
||||
// by then LastMatched* name an unrelated line. The fallback carries no patternId (no
|
||||
// line delivered its result). Health scripts carry neither stamp and stay on the last
|
||||
// matched line, which IS their result line.
|
||||
var isFallback = string.Equals(script.ResultSource, "agentexecutor_fallback", StringComparison.Ordinal);
|
||||
if (!isFallback)
|
||||
{
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
var patternId = script.ResultPatternId ?? _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
}
|
||||
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
DateTime now;
|
||||
if (isFallback && script.ExitObservedAtUtc.HasValue)
|
||||
now = ResolveOccurredAt(script.ExitObservedAtUtc, out derivedFromClock, out rawSourceTs);
|
||||
else
|
||||
now = ResolveOccurredAt(out derivedFromClock, out rawSourceTs);
|
||||
var sourceTs = isFallback ? script.ExitObservedAtUtc : script.ResultObservedAtUtc;
|
||||
var now = sourceTs.HasValue
|
||||
? ResolveOccurredAt(sourceTs, out derivedFromClock, out rawSourceTs)
|
||||
: ResolveOccurredAt(out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay (a previous enrollment's log) — do not emit at all. The early
|
||||
// return also skips MaybeEmitBootstrapDetected / MaybeEmitScriptTimeoutSuspected,
|
||||
|
||||
@@ -584,6 +584,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
["heldTails"] = health.HeldTails,
|
||||
["unanchoredPatterns"] = health.UnanchoredPatterns,
|
||||
["hsParseFailures"] = health.HealthScriptResultParseFailures,
|
||||
["overwriteRewinds"] = health.OverwriteRewinds,
|
||||
["overwriteBytesReprocessed"] = health.OverwriteBytesReprocessed,
|
||||
["verifyPasses"] = health.VerifyPasses,
|
||||
["verifiedBytes"] = health.VerifiedBytes,
|
||||
["agentVersion"] = _agentVersion ?? string.Empty,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(health.ImeAgentVersion))
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The reducer identifies a deadline incarnation through the due time carried in the
|
||||
/// payload (<see cref="SignalPayloadKeys.DeadlineDueAtUtc"/>), never through the signal
|
||||
/// time: the host stamps <c>DeadlineFired</c> with the due time (D-238), but the guards
|
||||
/// must stay correct for any stamp — a late fire after Modern Standby or a restart, a
|
||||
/// replayed signal, or a future switch to the firing clock. Without the key they fall back
|
||||
/// to the signal time (pre-key signal logs, where the two were equal by contract).
|
||||
/// </summary>
|
||||
public sealed class DeadlineFireTimeStampTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 9, 6, 31, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
|
||||
/// <summary>What the host posts: name plus due time, OccurredAtUtc = firing clock.</summary>
|
||||
private static Dictionary<string, string> FiredPayload(string deadlineName, DateTime dueAtUtc) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = deadlineName,
|
||||
[SignalPayloadKeys.DeadlineDueAtUtc] = dueAtUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
// ============================================================== helper
|
||||
|
||||
[Fact]
|
||||
public void DeadlineDueAtUtc_reads_the_payload_and_falls_back_to_the_signal_time()
|
||||
{
|
||||
var due = T0.AddMinutes(5);
|
||||
var fired = T0.AddMinutes(9);
|
||||
|
||||
var withKey = MakeSignal(1, DecisionSignalKind.DeadlineFired, fired, FiredPayload(DeadlineNames.HelloSafety, due));
|
||||
Assert.Equal(due, DecisionEngine.DeadlineDueAtUtc(withKey));
|
||||
Assert.Equal(DateTimeKind.Utc, DecisionEngine.DeadlineDueAtUtc(withKey).Kind);
|
||||
|
||||
var withoutKey = MakeSignal(2, DecisionSignalKind.DeadlineFired, fired,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety });
|
||||
Assert.Equal(fired, DecisionEngine.DeadlineDueAtUtc(withoutKey));
|
||||
|
||||
var garbage = MakeSignal(3, DecisionSignalKind.DeadlineFired, fired,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.DeadlineDueAtUtc] = "not-a-time" });
|
||||
Assert.Equal(fired, DecisionEngine.DeadlineDueAtUtc(garbage));
|
||||
}
|
||||
|
||||
// ============================================================== Classic: standby at the Hello prompt
|
||||
|
||||
[Fact]
|
||||
public void Late_HelloSafety_fire_stamps_the_resolution_and_the_grace_window_from_the_firing_clock()
|
||||
{
|
||||
// Session ac5660b8 shape: ESP exits, the user leaves the device at the Hello prompt, it
|
||||
// enters Modern Standby, HelloSafety is due during the sleep and fires ~3 min late after
|
||||
// wake. Fed with the firing clock as signal time, the reducer must stamp the Hello
|
||||
// timeout fact, the Finalizing step and the FinalizingGrace due time from that time —
|
||||
// the stamp policy is the host's choice (D-238), the reducer follows the signal.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-standby", "tenant", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(2))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3))).NewState;
|
||||
|
||||
var espExit = T0.AddMinutes(6).AddSeconds(50.2144855);
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspExiting, espExit)).NewState;
|
||||
var helloSafety = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(espExit.AddSeconds(300), helloSafety.DueAtUtc);
|
||||
|
||||
// Timer fires after wake, 2m 54s past due.
|
||||
var firedAt = helloSafety.DueAtUtc.AddSeconds(174);
|
||||
var finalizing = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DeadlineFired, firedAt,
|
||||
FiredPayload(DeadlineNames.HelloSafety, helloSafety.DueAtUtc)));
|
||||
|
||||
Assert.True(finalizing.Transition.Taken);
|
||||
Assert.Equal(SessionStage.Finalizing, finalizing.NewState.Stage);
|
||||
Assert.Equal("Timeout", finalizing.NewState.HelloOutcome!.Value);
|
||||
Assert.Equal(firedAt, finalizing.NewState.HelloResolvedUtc!.Value);
|
||||
|
||||
var grace = Assert.Single(finalizing.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||
Assert.Equal(firedAt.AddSeconds(5), grace.DueAtUtc);
|
||||
|
||||
// The chained grace fires on time and completes; nothing carries the old due time.
|
||||
var completed = engine.Reduce(finalizing.NewState, MakeSignal(6, DecisionSignalKind.DeadlineFired, grace.DueAtUtc,
|
||||
FiredPayload(DeadlineNames.FinalizingGrace, grace.DueAtUtc)));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, completed.NewState.Outcome);
|
||||
Assert.Contains(completed.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
}
|
||||
|
||||
// ============================================================== SelfDeploying guard C
|
||||
|
||||
[Fact]
|
||||
public void SelfDeploying_guard_C_matches_the_incarnation_by_payload_due_time_not_by_firing_clock()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sd-late", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState;
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
|
||||
Assert.Equal(T0.AddMinutes(8), armed.DueAtUtc);
|
||||
|
||||
// A stale fire of an OLDER incarnation (due T+5) arriving late: dead-end, deadline kept.
|
||||
var stale = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(12),
|
||||
FiredPayload(DeadlineNames.DeviceOnlyEspDetection, T0.AddMinutes(5))));
|
||||
Assert.False(stale.Transition.Taken);
|
||||
Assert.Equal("device_only_esp_detection_stale_due_at_mismatch", stale.Transition.DeadEndReason);
|
||||
Assert.Single(stale.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
|
||||
|
||||
// The real fire, 4 min late (standby), carries the armed due time: terminal.
|
||||
var real = engine.Reduce(stale.NewState, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(12),
|
||||
FiredPayload(DeadlineNames.DeviceOnlyEspDetection, armed.DueAtUtc)));
|
||||
Assert.True(real.Transition.Taken);
|
||||
Assert.Equal(SessionStage.Completed, real.NewState.Stage);
|
||||
}
|
||||
|
||||
// ============================================================== RealmJoin re-arm guard
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_rearm_guard_compares_the_payload_due_time_not_the_firing_clock()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("rj-late", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
var first = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
|
||||
// Simulate the activity-based re-arm: the armed incarnation is now due later.
|
||||
var rearmedDue = first.DueAtUtc.AddMinutes(25);
|
||||
var rearmed = state.ToBuilder()
|
||||
.CancelDeadline(DeadlineNames.RealmJoinTimeout)
|
||||
.AddDeadline(new ActiveDeadline(
|
||||
name: DeadlineNames.RealmJoinTimeout,
|
||||
dueAtUtc: rearmedDue,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }))
|
||||
.Build();
|
||||
|
||||
// The OLD incarnation's fire arrives late — after the re-armed due time on the clock.
|
||||
// Only the payload due time identifies it as superseded.
|
||||
var firedAt = rearmedDue.AddMinutes(1);
|
||||
var stale = engine.Reduce(rearmed, MakeSignal(7, DecisionSignalKind.DeadlineFired, firedAt,
|
||||
FiredPayload(DeadlineNames.RealmJoinTimeout, first.DueAtUtc)));
|
||||
Assert.False(stale.Transition.Taken);
|
||||
Assert.Equal("realmjoin_timeout_stale_superseded_by_rearm", stale.Transition.DeadEndReason);
|
||||
Assert.Null(stale.NewState.RealmJoinFacts.Outcome);
|
||||
|
||||
// The re-armed incarnation's own fire, same firing clock: timeout is applied.
|
||||
var real = engine.Reduce(stale.NewState, MakeSignal(8, DecisionSignalKind.DeadlineFired, firedAt,
|
||||
FiredPayload(DeadlineNames.RealmJoinTimeout, rearmedDue)));
|
||||
Assert.True(real.Transition.Taken);
|
||||
Assert.Equal("Timeout", real.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.Equal(SessionStage.Finalizing, real.NewState.Stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +706,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
var deadline = Assert.Single(signalStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
|
||||
Assert.Equal(T0.AddMinutes(7), deadline.DueAtUtc);
|
||||
|
||||
// DeadlineFired (OccurredAtUtc = DueAtUtc per scheduler contract) → terminal.
|
||||
// DeadlineFired at the due time (no payload due key → helper falls back to the signal time) → terminal.
|
||||
var step = engine.Reduce(signalStep.NewState, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(7),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection }));
|
||||
|
||||
|
||||
@@ -443,8 +443,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
// Cancel-then-rearm race: old deadline (DueAt=T+8) fires queued; before processing,
|
||||
// DeviceSetupProvisioningComplete arrives (signal at T+10) → cancel+rearm to DueAt=T+15.
|
||||
// The queued OLD fire (OccurredAtUtc=T+8) arrives. Guard C must dead-end WITHOUT
|
||||
// cancelling the new deadline.
|
||||
// The queued OLD fire arrives carrying its due time T+8 (no payload key here, so the
|
||||
// helper falls back to the signal time). Guard C must dead-end WITHOUT cancelling the
|
||||
// new deadline.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-11", "t", T0));
|
||||
|
||||
@@ -467,7 +468,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
firesPayload: new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection }));
|
||||
state = rearmedBuilder.Build();
|
||||
|
||||
// OLD queued fire arrives with OccurredAtUtc=T+8 — mismatches new deadline at T+15.
|
||||
// OLD queued fire arrives with due time T+8 — mismatches the new deadline at T+15.
|
||||
var step = engine.Reduce(state, MakeSignal(99, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection }));
|
||||
|
||||
|
||||
@@ -54,6 +54,37 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal("AccountSetup", roundtripped.Payload!["phase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSerializer_roundtrip_keeps_date_like_payload_strings_verbatim()
|
||||
{
|
||||
// The shared reader tokenizes ISO-8601 strings as dates; the string dictionaries must
|
||||
// still come back as the round-trip text the writer produced, never culture-formatted
|
||||
// (the DeadlineFired due time is parsed back with the "o" format on replay).
|
||||
var due = new DateTime(2026, 4, 20, 9, 55, 0, DateTimeKind.Utc);
|
||||
var original = new DecisionSignal(
|
||||
sessionSignalOrdinal: 7,
|
||||
sessionTraceOrdinal: 7,
|
||||
kind: DecisionSignalKind.DeadlineFired,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: due.AddMinutes(5),
|
||||
sourceOrigin: "DeadlineScheduler",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "hello_safety",
|
||||
summary: "deadline fired",
|
||||
derivationInputs: new Dictionary<string, string> { ["armedAt"] = "2026-04-20T09:50:00.0000000Z" }),
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
["deadline"] = "hello_safety",
|
||||
["deadlineDueAtUtc"] = due.ToString("O"),
|
||||
});
|
||||
|
||||
var roundtripped = SignalSerializer.Deserialize(SignalSerializer.Serialize(original));
|
||||
|
||||
Assert.Equal("2026-04-20T09:55:00.0000000Z", roundtripped.Payload!["deadlineDueAtUtc"]);
|
||||
Assert.Equal("2026-04-20T09:50:00.0000000Z", roundtripped.Evidence.DerivationInputs!["armedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSerializer_Deserialize_MissingEvidence_throws()
|
||||
{
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Analyze twin of <see cref="GatherRulesFunctionAuthorEndpointTests"/>: the rule Author is
|
||||
/// stamped from the caller's JWT on create, on a PUT-upsert of an unknown rule and on the
|
||||
/// template copy, and a true update keeps the original creator over editor and body.
|
||||
/// Before this pin, custom analyze rules were created as "Autopilot Monitor" and the edit
|
||||
/// path let the browser choose the author.
|
||||
/// </summary>
|
||||
public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "ANALYZE-CUSTOM-777";
|
||||
private const string TemplateRuleId = "ANALYZE-SEC-004";
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>();
|
||||
if (principal != null)
|
||||
{
|
||||
items["ClaimsPrincipal"] = principal;
|
||||
}
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
return context.Object;
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body)
|
||||
{
|
||||
var context = BuildContext(principal);
|
||||
var req = new Mock<HttpRequestData>(context);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static ClaimsPrincipal AlicePrincipal()
|
||||
=> Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com"));
|
||||
|
||||
private static (AnalyzeRulesFunction function, List<AnalyzeRule> stored) BuildFunction(
|
||||
AnalyzeRule? existingTenantRule = null, AnalyzeRule? globalRule = null)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
var stored = new List<AnalyzeRule>();
|
||||
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(
|
||||
globalRule == null ? new List<AnalyzeRule>() : new List<AnalyzeRule> { globalRule });
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(
|
||||
existingTenantRule == null ? new List<AnalyzeRule>() : new List<AnalyzeRule> { existingTenantRule });
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreRuleStateAsync(TenantId, It.IsAny<string>(), It.IsAny<RuleState>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId))
|
||||
.Callback<AnalyzeRule, string>((rule, _) => stored.Add(rule))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
return (new AnalyzeRulesFunction(NullLogger<AnalyzeRulesFunction>.Instance, service), stored);
|
||||
}
|
||||
|
||||
private static AnalyzeRule SpoofedPayload() => new()
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Proxy PAC unreachable",
|
||||
Description = "Custom",
|
||||
Severity = "warning",
|
||||
Category = "network",
|
||||
Explanation = "The PAC file could not be fetched.",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
Author = "Spoofed Author",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new() { Signal = "pac_failed", Source = "event_type", EventType = "network_pac_failed", Required = true }
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_stamps_author_from_jwt_not_from_body()
|
||||
{
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_falls_back_to_product_name_without_identifying_claims()
|
||||
{
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Autopilot Monitor", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt()
|
||||
{
|
||||
var (function, stored) = BuildFunction(existingTenantRule: null);
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body()
|
||||
{
|
||||
var existing = SpoofedPayload();
|
||||
existing.Author = "Original Creator";
|
||||
existing.CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
var (function, stored) = BuildFunction(existing);
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Original Creator", written.Author);
|
||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromTemplate_stamps_the_copying_admin_not_the_template_author()
|
||||
{
|
||||
var template = new AnalyzeRule
|
||||
{
|
||||
RuleId = TemplateRuleId,
|
||||
Title = "AutoLogon enabled for an unexpected user",
|
||||
Description = "Template",
|
||||
Severity = "warning",
|
||||
Category = "security",
|
||||
Explanation = "Template",
|
||||
IsBuiltIn = true,
|
||||
Author = "Autopilot Monitor",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true }
|
||||
},
|
||||
TemplateVariables = new List<TemplateVariable>
|
||||
{
|
||||
new() { Name = "allowed", Label = "Allowed", ConditionIndex = 0, Field = "value" }
|
||||
},
|
||||
};
|
||||
var (function, stored) = BuildFunction(globalRule: template);
|
||||
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "KIOSK-1" } });
|
||||
|
||||
var response = await function.CreateFromTemplate(req, TemplateRuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
Assert.Equal(TemplateRuleId, written.DerivedFromTemplateRuleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Observation-end closure of app rows: a row still InProgress when the session's observation
|
||||
/// ended becomes "Incomplete" (terminal, non-failure, outcome unknown). Pins the pure per-row
|
||||
/// predicate behind <see cref="TableStorageService.CloseOpenAppInstallsForSessionAsync"/> and
|
||||
/// the disclosed bucket in the app-metrics payload.
|
||||
/// </summary>
|
||||
public class AppInstallIncompleteCloseTests
|
||||
{
|
||||
private static TableEntity Row(string? status, DateTimeOffset? completedAt = null)
|
||||
{
|
||||
var row = new TableEntity("tenant", "session_App");
|
||||
if (status != null) row["Status"] = status;
|
||||
if (completedAt.HasValue) row["CompletedAt"] = completedAt.Value;
|
||||
return row;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("InProgress")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void ShouldClose_OpenRow(string? status)
|
||||
=> Assert.True(TableStorageService.ShouldCloseAsIncomplete(Row(status)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Succeeded")]
|
||||
[InlineData("Failed")]
|
||||
[InlineData("Incomplete")]
|
||||
public void ShouldClose_LeavesTerminalRowsAlone(string status)
|
||||
=> Assert.False(TableStorageService.ShouldCloseAsIncomplete(Row(status)));
|
||||
|
||||
[Fact]
|
||||
public void ShouldClose_NeverClosesARowWithAnEnd()
|
||||
{
|
||||
// Out-of-order arrival can leave a CompletedAt on a row whose status batch is late —
|
||||
// an end is an end.
|
||||
Assert.False(TableStorageService.ShouldCloseAsIncomplete(Row("InProgress", DateTimeOffset.UtcNow)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppMetrics_DisclosesIncompleteOutsideTheRate()
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(new[]
|
||||
{
|
||||
new AppInstallSummary { AppName = "App", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 30 },
|
||||
new AppInstallSummary { AppName = "App", Status = "Failed", TerminalState = "Error", DurationSeconds = 30 },
|
||||
new AppInstallSummary { AppName = "App", Status = "Incomplete" },
|
||||
});
|
||||
|
||||
var group = Assert.Single(payload.TopFailingApps);
|
||||
Assert.Equal(3, group.TotalInstalls);
|
||||
Assert.Equal(1, group.Incomplete);
|
||||
Assert.Equal(50.0, group.FailureRate);
|
||||
Assert.Equal(1, payload.TotalIncomplete);
|
||||
}
|
||||
}
|
||||
+24
@@ -155,6 +155,30 @@ public class AppInstallTerminalStateAndReconcileTests
|
||||
Assert.Equal("Succeeded", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_IncompleteIsSticky_AgainstLateProgressBatch()
|
||||
{
|
||||
// Closed by the observation-end step; a straggling started/progress batch must not reopen it.
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Incomplete" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Incomplete", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_RealTerminalOverridesIncomplete()
|
||||
{
|
||||
// The completion arrived after the close step: real evidence beats the hedge.
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Incomplete" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "Succeeded", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Succeeded", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4Guard_DoesNotFireWithoutStoredCompletedAt()
|
||||
{
|
||||
|
||||
@@ -175,7 +175,7 @@ public class BuiltInRulesTests
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
|
||||
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
|
||||
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
"resetPlatformScriptContext", "agentInvocationArgument", "agentExecutorCompleted",
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
"captureExitCode", "captureHResult",
|
||||
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
||||
|
||||
@@ -37,9 +37,9 @@ public class CertTenantBindingTests
|
||||
/// Tenant stamped into <c>device-cert-sample.pem</c> by the Intune MDM Device CA. The sample is
|
||||
/// a public-key-only cert from an internal test device; the GUID is the test tenant's own id.
|
||||
/// </summary>
|
||||
private static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
|
||||
internal static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
|
||||
|
||||
private static string SampleCertBase64()
|
||||
internal static string SampleCertBase64()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertTenantBindingTests).Assembly.Location)!;
|
||||
var pem = File.ReadAllText(Path.Combine(assemblyDir, DeviceSamplePem));
|
||||
@@ -351,7 +351,7 @@ public class CertTenantBindingTests
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
private static SecurityValidator BuildValidator(ILogger logger)
|
||||
internal static SecurityValidator BuildValidator(ILogger logger)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
@@ -390,7 +390,7 @@ public class CertTenantBindingTests
|
||||
deviceAssociationValidator: null);
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequestWithCert(
|
||||
internal static HttpRequestData BuildRequestWithCert(
|
||||
string certBase64,
|
||||
IDictionary<object, object>? items = null)
|
||||
{
|
||||
|
||||
+55
-2
@@ -1,12 +1,14 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the pure fold behind GET metrics/mcp-usage/organization: one item per account, the
|
||||
/// delegated (MSP) marker from the row's home tenant, and the three windows (today / month / range).
|
||||
/// Tests for the pure folds behind GET metrics/mcp-usage/organization (and its global twin): one item per
|
||||
/// account, the delegated (MSP) marker from the row's home tenant, the three windows (today / month / range),
|
||||
/// and the tenant's organization windows over every account.
|
||||
/// </summary>
|
||||
public class McpOrganizationUsageAggregationTests
|
||||
{
|
||||
@@ -86,6 +88,57 @@ public class McpOrganizationUsageAggregationTests
|
||||
Assert.Equal(newer, items[1].LastRequestAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationQuota_SumsTodayAndMonthOverEveryAccount_AndCarriesTheTenantLimits()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row(Member, "20260815", 100), // last month: neither window
|
||||
Row(Member, "20260901", 20), // month
|
||||
Row(Member, "20260902", 2), // today + month
|
||||
Row(MspAdmin, "20260902", 5, home: MspHome), // delegated reads count against the same windows
|
||||
Row(Member, "20260903", 7), // read window past today (range only)
|
||||
};
|
||||
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
|
||||
|
||||
Assert.Equal("pro", quota.TenantPlan);
|
||||
Assert.Equal(1500, quota.DailyLimit);
|
||||
Assert.Equal(15000, quota.MonthlyLimit);
|
||||
Assert.Equal(7, quota.DailyUsed);
|
||||
Assert.Equal(27, quota.MonthlyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationDaily_SumsEveryAccountPerDay_InsideTheRange_OldestFirst()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row(Member, "20260815", 100), // before the range
|
||||
Row(Member, "20260902", 2),
|
||||
Row(MspAdmin, "20260902", 5, home: MspHome), // same day, delegated — one bar
|
||||
Row(Member, "20260901", 20),
|
||||
Row(Member, "20260903", 7), // after the range (read window)
|
||||
};
|
||||
|
||||
var daily = McpUsageMetricsFunction.BuildOrganizationDaily(rows, "20260901", "20260902");
|
||||
|
||||
Assert.Equal(new[] { ("20260901", 20L), ("20260902", 7L) }, daily.Select(d => (d.Date, d.Requests)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
|
||||
{
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
|
||||
|
||||
Assert.Equal(0, quota.DailyLimit);
|
||||
Assert.Equal(0, quota.MonthlyLimit);
|
||||
Assert.Equal(3, quota.DailyUsed);
|
||||
Assert.Equal(3, quota.MonthlyUsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-02", "20260902")]
|
||||
[InlineData("20260902", "20260902")]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent security gate stamps two dimensions onto the request row through
|
||||
/// <c>FunctionContext.Items</c>: the tenant it proved (<c>ValidatedTenantId</c>) and the outcome of
|
||||
/// the device-validation chain (<c>DeviceValidation</c>). These tests pin
|
||||
/// - that a request which passes the gate carries both, and one rejected before the chain
|
||||
/// carries neither (a rejected certificate must not attribute the row to the claimed tenant),
|
||||
/// - that the middleware takes the proven tenant before the <c>X-Tenant-Id</c> header and never
|
||||
/// takes the header on a route that does not validate it — register and config take the tenant
|
||||
/// from body/query, so a client-sent header there is attacker-controlled.
|
||||
/// </summary>
|
||||
public class RequestRowMarkersTests
|
||||
{
|
||||
private const string RegisterPath = "/api/agent/register-session";
|
||||
private const string ConfigPath = "/api/agent/config";
|
||||
private const string TelemetryPath = "/api/agent/telemetry";
|
||||
private const string OtherTenant = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
// ---------------------------------------------------------------- validator stamps
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequest_PassingTheGate_StampsProvenTenantAndDeviceValidation()
|
||||
{
|
||||
var items = new Dictionary<object, object>();
|
||||
var validator = CertTenantBindingTests.BuildValidator(Mock.Of<ILogger>());
|
||||
var tenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items),
|
||||
tenantId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(tenantId, Assert.Contains(RequestRowMarkers.ValidatedTenantKey, items));
|
||||
// The harness runs the tenant without a device validator (AllowInsecureAgentRequests).
|
||||
Assert.Equal(RequestRowMarkers.DeviceValidation.None, Assert.Contains(RequestRowMarkers.DeviceValidationKey, items));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequest_CertificateOfAnotherTenant_StampsNeither()
|
||||
{
|
||||
var items = new Dictionary<object, object>();
|
||||
var validator = CertTenantBindingTests.BuildValidator(Mock.Of<ILogger>());
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items),
|
||||
tenantId: OtherTenant);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// The row must not be attributed to the tenant the caller merely claimed …
|
||||
Assert.DoesNotContain(RequestRowMarkers.ValidatedTenantKey, items);
|
||||
// … and the device chain never ran, so there is no outcome to report.
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidationKey, items);
|
||||
// The binding outcome itself still rides along (denominator for the cert-tenant telemetry).
|
||||
Assert.Equal(CertTenantBinding.Outcome.Mismatch, Assert.Contains(CertTenantBinding.RequestItemKey, items));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceValidationValue_IsTheValidatorName_AndNoneForUnknown()
|
||||
{
|
||||
Assert.Equal("None", RequestRowMarkers.DeviceValidationValue(ValidatorType.Unknown));
|
||||
foreach (var validator in Enum.GetValues<ValidatorType>().Where(v => v != ValidatorType.Unknown))
|
||||
Assert.Equal(validator.ToString(), RequestRowMarkers.DeviceValidationValue(validator));
|
||||
|
||||
// The three non-validator values must never collide with a validator name.
|
||||
var validatorNames = Enum.GetNames<ValidatorType>();
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.None, validatorNames);
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.Transient, validatorNames);
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.Rejected, validatorNames);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- middleware attribution
|
||||
|
||||
[Fact]
|
||||
public void ResolveTenantId_PolicyTenantWins()
|
||||
{
|
||||
var items = new Dictionary<object, object> { [RequestRowMarkers.ValidatedTenantKey] = OtherTenant };
|
||||
Assert.Equal("policy-tenant", RequestTelemetryMiddleware.ResolveTenantId("policy-tenant", items, RegisterPath, OtherTenant));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegisterPath)]
|
||||
[InlineData(ConfigPath)]
|
||||
[InlineData(TelemetryPath)]
|
||||
public void ResolveTenantId_ProvenTenantBeatsTheHeaderOnEveryRoute(string path)
|
||||
{
|
||||
var proven = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
var items = new Dictionary<object, object> { [RequestRowMarkers.ValidatedTenantKey] = proven };
|
||||
Assert.Equal(proven, RequestTelemetryMiddleware.ResolveTenantId(null, items, path, OtherTenant));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegisterPath)]
|
||||
[InlineData(ConfigPath)]
|
||||
[InlineData("/api/agent/upload-url")]
|
||||
[InlineData("/api/bootstrap/register-session")]
|
||||
public void ResolveTenantId_HeaderIsIgnoredOnRoutesThatDoNotValidateIt(string path)
|
||||
{
|
||||
// Without a stamp from the validator (the request never passed the gate) a client-sent
|
||||
// header must not attribute the row to any tenant.
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, new Dictionary<object, object>(), path, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTenantId_HeaderIsHonoredOnlyOnTrustedRoutes_AndOnlyAsGuid()
|
||||
{
|
||||
var none = new Dictionary<object, object>();
|
||||
Assert.Equal(OtherTenant, RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, OtherTenant));
|
||||
Assert.Equal(OtherTenant, RequestTelemetryMiddleware.ResolveTenantId(null, none, "/api/agent/distress", OtherTenant));
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, "not-a-guid"));
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pure pieces of <see cref="RuleSubmissionService"/> (id suggestion, repo file, snapshot
|
||||
/// stripping, attribution shape) and the review state machine against mocked repositories.
|
||||
/// </summary>
|
||||
public class RuleSubmissionServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
// ── id suggestion ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Suggest_is_max_plus_one_and_never_fills_a_gap()
|
||||
{
|
||||
// 001 and 003 exist, 002 is a retired id — the next is 004, not 002.
|
||||
var taken = new[] { "ANALYZE-NET-001", "ANALYZE-NET-003", "ANALYZE-APP-018", "GATHER-NET-009" };
|
||||
Assert.Equal("ANALYZE-NET-004", RuleSubmissionService.SuggestPublishedRuleId("analyze", "network", taken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suggest_starts_at_001_for_an_empty_category_and_maps_the_kind_convention()
|
||||
{
|
||||
Assert.Equal("ANALYZE-NET-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "network", Array.Empty<string>()));
|
||||
Assert.Equal("GATHER-DEVICE-001", RuleSubmissionService.SuggestPublishedRuleId("gather", "device", Array.Empty<string>()));
|
||||
Assert.Equal("ANALYZE-DEV-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "device", Array.Empty<string>()));
|
||||
Assert.Equal("ANALYZE-ENRL-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "enrollment", Array.Empty<string>()));
|
||||
Assert.Equal("GATHER-ENROLL-001", RuleSubmissionService.SuggestPublishedRuleId("gather", "enrollment", Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suggest_is_case_insensitive_over_taken_ids()
|
||||
{
|
||||
Assert.Equal("ANALYZE-SEC-008", RuleSubmissionService.SuggestPublishedRuleId("analyze", "security", new[] { "analyze-sec-007" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suggest_falls_back_to_the_upper_cased_category_when_unmapped()
|
||||
{
|
||||
Assert.Equal("ANALYZE-OFFICE-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "office", Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// ── freeze + repo file ──────────────────────────────────────────────────
|
||||
|
||||
private static AnalyzeRule CustomAnalyzeRule() => new()
|
||||
{
|
||||
RuleId = "ANALYZE-CUSTOM-003",
|
||||
Title = "Proxy PAC unreachable",
|
||||
Description = "PAC download fails",
|
||||
Severity = "warning",
|
||||
Category = "network",
|
||||
Version = "1.2.0",
|
||||
Author = "Alice Admin",
|
||||
Enabled = false,
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Provenance = "embedded",
|
||||
MarkSessionAsFailed = true,
|
||||
Notify = true,
|
||||
NotifyChannelIds = new List<string> { "chan-1" },
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
Explanation = "The PAC file could not be fetched.",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new() { Signal = "pac_failed", Source = "event_type", EventType = "network_pac_failed", Required = true },
|
||||
},
|
||||
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
UpdatedAt = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Freeze_strips_server_and_tenant_fields_and_keeps_the_rule()
|
||||
{
|
||||
var json = RuleSubmissionService.FreezeRule(CustomAnalyzeRule());
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
foreach (var stripped in new[] { "isBuiltIn", "provenance", "createdAt", "updatedAt", "markSessionAsFailed", "notifyDefault", "notify", "notifyChannelIds" })
|
||||
Assert.False(node.ContainsKey(stripped), $"{stripped} must not be frozen");
|
||||
|
||||
Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue<string>());
|
||||
Assert.Equal("Alice Admin", node["author"]!.GetValue<string>());
|
||||
Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue<string>());
|
||||
Assert.Single(node["conditions"]!.AsArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepoFile_carries_schema_pointer_assigned_id_credit_and_community_flag_in_repo_order()
|
||||
{
|
||||
var s = new RuleSubmission
|
||||
{
|
||||
RuleKind = RuleSubmissionKinds.Analyze,
|
||||
RuleJson = RuleSubmissionService.FreezeRule(CustomAnalyzeRule()),
|
||||
AttributionName = RuleAttributionModes.AnonymousAuthor,
|
||||
};
|
||||
|
||||
var file = RuleSubmissionService.BuildRepoFile(s, "ANALYZE-NET-002");
|
||||
|
||||
Assert.Equal("rules/analyze/ANALYZE-NET-002.json", file.Path);
|
||||
Assert.EndsWith("\n", file.Content);
|
||||
var node = JsonNode.Parse(file.Content)!.AsObject();
|
||||
var keys = node.Select(kv => kv.Key).ToList();
|
||||
Assert.Equal(new[] { "$schema", "ruleId", "title", "description", "severity", "category", "version", "author", "enabled", "isCommunity" }, keys.Take(10));
|
||||
Assert.Equal("../schema/analyze-rule.schema.json", node["$schema"]!.GetValue<string>());
|
||||
Assert.Equal("ANALYZE-NET-002", node["ruleId"]!.GetValue<string>());
|
||||
Assert.Equal("Community contribution", node["author"]!.GetValue<string>());
|
||||
Assert.True(node["isCommunity"]!.GetValue<bool>());
|
||||
// Analyze rules ship enabled — the tenant's own toggle state does not travel.
|
||||
Assert.True(node["enabled"]!.GetValue<bool>());
|
||||
Assert.False(node.ContainsKey("derivedFromTemplateRuleId"));
|
||||
Assert.False(node.ContainsKey("isBuiltIn"));
|
||||
Assert.Contains("\"explanation\": \"The PAC file could not be fetched.\"", file.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepoFile_for_a_gather_rule_ships_disabled_and_without_severity()
|
||||
{
|
||||
var gather = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-CUSTOM-001",
|
||||
Title = "BIOS config",
|
||||
Description = "Reads the BIOS registry block",
|
||||
Category = "device",
|
||||
Enabled = true,
|
||||
IsBuiltIn = false,
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Vendor\\BIOS",
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_bios_config",
|
||||
};
|
||||
var s = new RuleSubmission
|
||||
{
|
||||
RuleKind = RuleSubmissionKinds.Gather,
|
||||
RuleJson = RuleSubmissionService.FreezeRule(gather),
|
||||
AttributionName = "Contoso IT",
|
||||
};
|
||||
|
||||
var file = RuleSubmissionService.BuildRepoFile(s, "GATHER-DEVICE-009");
|
||||
|
||||
Assert.Equal("rules/gather/GATHER-DEVICE-009.json", file.Path);
|
||||
var node = JsonNode.Parse(file.Content)!.AsObject();
|
||||
Assert.Equal("../schema/gather-rule.schema.json", node["$schema"]!.GetValue<string>());
|
||||
Assert.False(node["enabled"]!.GetValue<bool>());
|
||||
Assert.Equal("Contoso IT", node["author"]!.GetValue<string>());
|
||||
Assert.False(node.ContainsKey("severity"));
|
||||
// No \u escaping of the backslash-heavy registry path beyond JSON's own rules.
|
||||
Assert.Contains("HKLM\\\\SOFTWARE\\\\Vendor\\\\BIOS", file.Content);
|
||||
}
|
||||
|
||||
// ── attribution ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Alice A.")]
|
||||
[InlineData("Contoso IT & Ops (EMEA)")]
|
||||
[InlineData("Zoë Müller-Lüdenscheidt")]
|
||||
public void AttributionName_accepts_names(string name)
|
||||
=> Assert.Equal(name, RuleSubmissionService.ValidateAttributionName(name));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("<script>")]
|
||||
[InlineData("https://example.com")]
|
||||
[InlineData("line\nbreak")]
|
||||
public void AttributionName_rejects_markup_urls_and_control_characters(string name)
|
||||
=> Assert.Throws<ArgumentException>(() => RuleSubmissionService.ValidateAttributionName(name));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(" alice@contoso.com ", "alice@contoso.com")]
|
||||
public void Email_is_trimmed_or_null(string? input, string? expected)
|
||||
=> Assert.Equal(expected, RuleSubmissionService.NormalizeEmail(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice")]
|
||||
[InlineData("@contoso.com")]
|
||||
[InlineData("alice@")]
|
||||
[InlineData("a@b@c")]
|
||||
[InlineData("alice @contoso.com")]
|
||||
public void Email_rejects_non_addresses(string input)
|
||||
=> Assert.Throws<ArgumentException>(() => RuleSubmissionService.NormalizeEmail(input));
|
||||
|
||||
// ── review state machine ────────────────────────────────────────────────
|
||||
|
||||
private static (RuleSubmissionService service, Mock<IRuleSubmissionRepository> repo) Build(
|
||||
RuleSubmission row, IEnumerable<string>? globalAnalyzeIds = null, IEnumerable<string>? reservedIds = null)
|
||||
{
|
||||
var repo = new Mock<IRuleSubmissionRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.GetAsync(row.SubmissionId)).ReturnsAsync(row);
|
||||
repo.Setup(r => r.UpdateAsync(It.IsAny<RuleSubmission>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.GetReservedPublishedRuleIdsAsync())
|
||||
.ReturnsAsync(new HashSet<string>(reservedIds ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var rules = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
rules.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(
|
||||
(globalAnalyzeIds ?? Array.Empty<string>()).Select(id => new AnalyzeRule { RuleId = id, IsBuiltIn = true }).ToList());
|
||||
rules.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
|
||||
var metrics = new Mock<IMetricsRepository>(MockBehavior.Loose);
|
||||
var config = new Mock<TenantConfigurationService>(MockBehavior.Loose,
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var service = new RuleSubmissionService(repo.Object, rules.Object, metrics.Object, config.Object,
|
||||
NullLogger<RuleSubmissionService>.Instance);
|
||||
return (service, repo);
|
||||
}
|
||||
|
||||
private static RuleSubmission PendingRow() => new()
|
||||
{
|
||||
SubmissionId = "a1b2c3d4e5f6",
|
||||
BatchId = "b",
|
||||
TenantId = TenantId,
|
||||
RuleKind = RuleSubmissionKinds.Analyze,
|
||||
SourceRuleId = "ANALYZE-CUSTOM-003",
|
||||
Title = "Proxy PAC unreachable",
|
||||
Category = "network",
|
||||
RuleJson = RuleSubmissionService.FreezeRule(CustomAnalyzeRule()),
|
||||
SubmittedBy = "alice@contoso.com",
|
||||
SubmittedByName = "Alice Admin",
|
||||
SubmittedAt = DateTime.UtcNow,
|
||||
Status = RuleSubmissionStatuses.Pending,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_requires_a_reserved_id_of_the_same_kind()
|
||||
{
|
||||
var (service, _) = Build(PendingRow());
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve" }, "ga@example.com"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-CUSTOM-009" }, "ga@example.com"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "GATHER-NET-002" }, "ga@example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_refuses_an_id_taken_by_the_catalog_or_another_approval()
|
||||
{
|
||||
var (service, _) = Build(PendingRow(), globalAnalyzeIds: new[] { "ANALYZE-NET-001" }, reservedIds: new[] { "ANALYZE-NET-002" });
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-NET-001" }, "ga@example.com"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-NET-002" }, "ga@example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_stores_id_comment_adaptation_flag_and_reviewer()
|
||||
{
|
||||
var (service, repo) = Build(PendingRow());
|
||||
RuleSubmission? written = null;
|
||||
repo.Setup(r => r.UpdateAsync(It.IsAny<RuleSubmission>())).Callback<RuleSubmission>(s => written = s).ReturnsAsync(true);
|
||||
|
||||
var result = await service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-NET-003", ReviewComment = "Taken, will generalise the title.", WillBeAdapted = true },
|
||||
"ga@example.com");
|
||||
|
||||
Assert.Same(result, written);
|
||||
Assert.Equal(RuleSubmissionStatuses.Approved, result.Status);
|
||||
Assert.Equal("ANALYZE-NET-003", result.PublishedRuleId);
|
||||
Assert.True(result.WillBeAdapted);
|
||||
Assert.Equal("Taken, will generalise the title.", result.ReviewComment);
|
||||
Assert.Equal("ga@example.com", result.ReviewedBy);
|
||||
Assert.NotNull(result.ReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decline_requires_a_comment_and_clears_the_published_id()
|
||||
{
|
||||
var row = PendingRow();
|
||||
row.Status = RuleSubmissionStatuses.Approved;
|
||||
row.PublishedRuleId = "ANALYZE-NET-003";
|
||||
var (service, _) = Build(row);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "decline" }, "ga@example.com"));
|
||||
|
||||
var result = await service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "decline", ReviewComment = "Too tenant-specific." }, "ga@example.com");
|
||||
|
||||
Assert.Equal(RuleSubmissionStatuses.Declined, result.Status);
|
||||
Assert.Null(result.PublishedRuleId);
|
||||
Assert.False(result.WillBeAdapted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Published_is_derived_and_final()
|
||||
{
|
||||
var row = PendingRow();
|
||||
row.Status = RuleSubmissionStatuses.Approved;
|
||||
row.PublishedRuleId = "ANALYZE-NET-003";
|
||||
var (service, _) = Build(row, globalAnalyzeIds: new[] { "ANALYZE-NET-003" });
|
||||
|
||||
var items = await service.ToItemsAsync(new[] { row });
|
||||
Assert.Equal(RuleSubmissionStatuses.Published, items[0].Status);
|
||||
Assert.Equal(RuleSubmissionStatuses.Approved, row.Status);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "decline", ReviewComment = "too late" }, "ga@example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approved_but_unpublished_stays_approved_on_the_wire()
|
||||
{
|
||||
var row = PendingRow();
|
||||
row.Status = RuleSubmissionStatuses.Approved;
|
||||
row.PublishedRuleId = "ANALYZE-NET-003";
|
||||
var (service, _) = Build(row, globalAnalyzeIds: new[] { "ANALYZE-NET-001" });
|
||||
|
||||
var items = await service.ToItemsAsync(new[] { row });
|
||||
Assert.Equal(RuleSubmissionStatuses.Approved, items[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_only_pending_and_only_own_tenant()
|
||||
{
|
||||
var row = PendingRow();
|
||||
var (service, _) = Build(row);
|
||||
|
||||
Assert.False(await service.WithdrawAsync("22222222-2222-2222-2222-222222222222", "a1b2c3d4e5f6", crossTenantAllowed: false));
|
||||
Assert.True(await service.WithdrawAsync(TenantId, "a1b2c3d4e5f6", crossTenantAllowed: false));
|
||||
Assert.Equal(RuleSubmissionStatuses.Withdrawn, row.Status);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.WithdrawAsync(TenantId, "a1b2c3d4e5f6", crossTenantAllowed: false));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "approve", PublishedRuleId = "ANALYZE-NET-003" }, "ga@example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Operator_delete_passes_through_to_the_repository()
|
||||
{
|
||||
var (service, repo) = Build(PendingRow());
|
||||
repo.Setup(r => r.DeleteAsync("a1b2c3d4e5f6")).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await service.DeleteAsync("a1b2c3d4e5f6"));
|
||||
repo.Verify(r => r.DeleteAsync("a1b2c3d4e5f6"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_decision_is_a_bad_request()
|
||||
{
|
||||
var (service, _) = Build(PendingRow());
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ReviewAsync("a1b2c3d4e5f6",
|
||||
new ReviewRuleSubmissionRequest { Decision = "maybe" }, "ga@example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detail_returns_the_typed_rule_suggested_id_and_repo_file()
|
||||
{
|
||||
var (service, _) = Build(PendingRow(), globalAnalyzeIds: new[] { "ANALYZE-NET-001" });
|
||||
|
||||
var detail = await service.GetDetailAsync("a1b2c3d4e5f6");
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.NotNull(detail!.AnalyzeRule);
|
||||
Assert.Null(detail.GatherRule);
|
||||
Assert.Equal("ANALYZE-CUSTOM-003", detail.AnalyzeRule!.RuleId);
|
||||
Assert.False(detail.AnalyzeRule.IsBuiltIn);
|
||||
Assert.Equal("ANALYZE-NET-002", detail.SuggestedPublishedRuleId);
|
||||
Assert.Equal("rules/analyze/ANALYZE-NET-002.json", detail.RepoFile!.Path);
|
||||
}
|
||||
|
||||
// ── submit ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static (RuleSubmissionService service, List<RuleSubmission> stored) BuildForSubmit(
|
||||
List<AnalyzeRule> tenantAnalyze, List<RuleSubmission>? existing = null, TenantConfiguration? config = null)
|
||||
{
|
||||
var stored = new List<RuleSubmission>();
|
||||
var repo = new Mock<IRuleSubmissionRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.GetForTenantAsync(TenantId)).ReturnsAsync(existing ?? new List<RuleSubmission>());
|
||||
repo.Setup(r => r.AddAsync(It.IsAny<RuleSubmission>())).Callback<RuleSubmission>(stored.Add).ReturnsAsync(true);
|
||||
repo.Setup(r => r.GetReservedPublishedRuleIdsAsync()).ReturnsAsync(new HashSet<string>());
|
||||
|
||||
var rules = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
rules.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantAnalyze);
|
||||
rules.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(new List<GatherRule>());
|
||||
rules.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
|
||||
rules.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
|
||||
var metrics = new Mock<IMetricsRepository>(MockBehavior.Loose);
|
||||
metrics.Setup(m => m.GetRuleStatsAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(new List<RuleStatsEntry>
|
||||
{
|
||||
new() { RuleId = "ANALYZE-CUSTOM-003", FireCount = 4, SessionsEvaluated = 40, EvaluationCount = 41 },
|
||||
new() { RuleId = "ANALYZE-CUSTOM-003", FireCount = 1, SessionsEvaluated = 10, EvaluationCount = 10 },
|
||||
new() { RuleId = "ANALYZE-CUSTOM-999", FireCount = 99, SessionsEvaluated = 99, EvaluationCount = 99 },
|
||||
});
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>(MockBehavior.Loose);
|
||||
var configService = new Mock<TenantConfigurationService>(MockBehavior.Loose, configRepo.Object,
|
||||
NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
configService.Setup(c => c.GetConfigurationIfExistsAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
var service = new RuleSubmissionService(repo.Object, rules.Object, metrics.Object, configService.Object,
|
||||
NullLogger<RuleSubmissionService>.Instance);
|
||||
return (service, stored);
|
||||
}
|
||||
|
||||
private static SubmitRuleSubmissionsRequest Request(params string[] ruleIds) => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Items = ruleIds.Select(id => new RuleSubmissionItemRef { Kind = "analyze", RuleId = id }).ToList(),
|
||||
Comment = "Fires when the PAC download fails during ESP.",
|
||||
AttributionMode = RuleAttributionModes.Anonymous,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_freezes_the_tenant_rule_records_findings_and_fire_stats()
|
||||
{
|
||||
var (service, stored) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() });
|
||||
|
||||
var created = await service.SubmitAsync(TenantId, Request("ANALYZE-CUSTOM-003"), "alice@contoso.com", "Alice Admin");
|
||||
|
||||
var s = Assert.Single(created);
|
||||
Assert.Same(s, Assert.Single(stored));
|
||||
Assert.Equal(12, s.SubmissionId.Length);
|
||||
Assert.Equal(12, s.BatchId.Length);
|
||||
Assert.Equal(RuleSubmissionStatuses.Pending, s.Status);
|
||||
Assert.Equal("Community contribution", s.AttributionName);
|
||||
Assert.Null(s.ContactEmail);
|
||||
Assert.Equal("Proxy PAC unreachable", s.Title);
|
||||
Assert.Equal("network", s.Category);
|
||||
Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId);
|
||||
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-004"));
|
||||
Assert.DoesNotContain(s.ValidationFindings, f => f.Level == "error");
|
||||
Assert.Equal(5, s.SourceFireStats!.FireCount);
|
||||
Assert.Equal(50, s.SourceFireStats.SessionsEvaluated);
|
||||
Assert.DoesNotContain("isBuiltIn", s.RuleJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_rejects_built_in_and_unknown_rules_without_storing_anything()
|
||||
{
|
||||
var builtIn = CustomAnalyzeRule();
|
||||
builtIn.RuleId = "ANALYZE-NET-001";
|
||||
builtIn.IsBuiltIn = true;
|
||||
var (service, stored) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule(), builtIn });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.SubmitAsync(TenantId, Request("ANALYZE-CUSTOM-003", "ANALYZE-NET-001", "ANALYZE-CUSTOM-404"), "alice@contoso.com", "Alice Admin"));
|
||||
|
||||
Assert.Contains("ANALYZE-NET-001", ex.Message);
|
||||
Assert.Contains("ANALYZE-CUSTOM-404", ex.Message);
|
||||
Assert.Empty(stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_rejects_a_rule_whose_draft_validation_fails()
|
||||
{
|
||||
var broken = CustomAnalyzeRule();
|
||||
broken.Conditions = new List<RuleCondition>();
|
||||
var (service, stored) = BuildForSubmit(new List<AnalyzeRule> { broken });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.SubmitAsync(TenantId, Request("ANALYZE-CUSTOM-003"), "alice@contoso.com", "Alice Admin"));
|
||||
|
||||
Assert.Contains("at least one condition", ex.Message);
|
||||
Assert.Empty(stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_refuses_a_second_pending_submission_of_the_same_rule()
|
||||
{
|
||||
var (service, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() }, existing: new List<RuleSubmission> { PendingRow() });
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.SubmitAsync(TenantId, Request("ANALYZE-CUSTOM-003"), "alice@contoso.com", "Alice Admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_enforces_the_daily_cap()
|
||||
{
|
||||
var existing = Enumerable.Range(0, RuleSubmissionService.MaxSubmissionsPerTenantPerDay)
|
||||
.Select(i => { var r = PendingRow(); r.SourceRuleId = $"OTHER-{i}"; r.Status = RuleSubmissionStatuses.Declined; return r; })
|
||||
.ToList();
|
||||
var (service, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() }, existing);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.SubmitAsync(TenantId, Request("ANALYZE-CUSTOM-003"), "alice@contoso.com", "Alice Admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_organization_attribution_uses_the_company_name_and_fails_without_one()
|
||||
{
|
||||
var withName = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.example", CompanyName = "Contoso Ltd." };
|
||||
var (service, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() }, config: withName);
|
||||
var request = Request("ANALYZE-CUSTOM-003");
|
||||
request.AttributionMode = RuleAttributionModes.Organization;
|
||||
|
||||
var created = await service.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin");
|
||||
Assert.Equal("Contoso Ltd.", created[0].AttributionName);
|
||||
|
||||
var (noConfig, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() }, config: null);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => noConfig.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_stores_the_contact_email_when_given()
|
||||
{
|
||||
var (service, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() });
|
||||
var request = Request("ANALYZE-CUSTOM-003");
|
||||
request.Email = " alice.reply@contoso.com ";
|
||||
|
||||
var created = await service.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin");
|
||||
Assert.Equal("alice.reply@contoso.com", created[0].ContactEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_person_attribution_freezes_the_requested_name()
|
||||
{
|
||||
var (service, _) = BuildForSubmit(new List<AnalyzeRule> { CustomAnalyzeRule() });
|
||||
var request = Request("ANALYZE-CUSTOM-003");
|
||||
request.AttributionMode = RuleAttributionModes.Person;
|
||||
request.AttributionName = " Alice A. ";
|
||||
|
||||
var created = await service.SubmitAsync(TenantId, request, "alice@contoso.com", "Alice Admin");
|
||||
Assert.Equal("Alice A.", created[0].AttributionName);
|
||||
Assert.Equal(RuleAttributionModes.Person, created[0].AttributionMode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire pins for the rule-submission responses (typed from the start — the anonymous side is
|
||||
/// the literal a client sees, so a reordered or renamed property fails here first).
|
||||
/// </summary>
|
||||
public class RuleSubmissionsWireParityTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 9, 9, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static RuleSubmissionItem Item() => new()
|
||||
{
|
||||
SubmissionId = "a1b2c3d4e5f6",
|
||||
BatchId = "0f0e0d0c0b0a",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
RuleKind = "analyze",
|
||||
SourceRuleId = "ANALYZE-CUSTOM-003",
|
||||
Title = "Proxy PAC unreachable",
|
||||
Category = "network",
|
||||
Comment = "Fires when the PAC download fails.",
|
||||
SubmittedBy = "alice@contoso.com",
|
||||
SubmittedByName = "Alice Admin",
|
||||
ContactEmail = "alice.reply@contoso.com",
|
||||
AttributionMode = "anonymous",
|
||||
AttributionName = "Community contribution",
|
||||
SubmittedAt = T0,
|
||||
Status = "approved",
|
||||
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" } },
|
||||
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
|
||||
ReviewedBy = "ga@example.com",
|
||||
ReviewedAt = T0.AddDays(1),
|
||||
ReviewComment = "Taken.",
|
||||
WillBeAdapted = true,
|
||||
PublishedRuleId = "ANALYZE-NET-002",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
};
|
||||
|
||||
private static object ItemLiteral() => new
|
||||
{
|
||||
submissionId = "a1b2c3d4e5f6",
|
||||
batchId = "0f0e0d0c0b0a",
|
||||
tenantId = "11111111-1111-1111-1111-111111111111",
|
||||
ruleKind = "analyze",
|
||||
sourceRuleId = "ANALYZE-CUSTOM-003",
|
||||
title = "Proxy PAC unreachable",
|
||||
category = "network",
|
||||
comment = "Fires when the PAC download fails.",
|
||||
submittedBy = "alice@contoso.com",
|
||||
submittedByName = "Alice Admin",
|
||||
contactEmail = "alice.reply@contoso.com",
|
||||
attributionMode = "anonymous",
|
||||
attributionName = "Community contribution",
|
||||
submittedAt = T0,
|
||||
status = "approved",
|
||||
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-004" } },
|
||||
sourceFireStats = new { days = 30, fireCount = 42, sessionsEvaluated = 310, evaluationCount = 312 },
|
||||
reviewedBy = "ga@example.com",
|
||||
reviewedAt = T0.AddDays(1),
|
||||
reviewComment = "Taken.",
|
||||
willBeAdapted = true,
|
||||
publishedRuleId = "ANALYZE-NET-002",
|
||||
derivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Submit_response_shape()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, message = "Rule submitted for review.", batchId = "0f0e0d0c0b0a", submissions = new List<object> { ItemLiteral() } },
|
||||
new SubmitRuleSubmissionsResponse { Success = true, Message = "Rule submitted for review.", BatchId = "0f0e0d0c0b0a", Submissions = new List<RuleSubmissionItem> { Item() } });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_nonPaged_has_no_nextLink_key()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, count = 1, submissions = new List<object> { ItemLiteral() } },
|
||||
new RuleSubmissionListResponse { Success = true, Count = 1, Submissions = new List<RuleSubmissionItem> { Item() } });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_paged_carries_nextLink()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, count = 1, submissions = new List<object> { ItemLiteral() }, nextLink = "/api/global/rule-submissions?pageSize=20&continuation=abc" },
|
||||
new RuleSubmissionListResponse { Success = true, Count = 1, Submissions = new List<RuleSubmissionItem> { Item() }, NextLink = "/api/global/rule-submissions?pageSize=20&continuation=abc" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_last_page_drops_the_nextLink_key()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, count = 0, submissions = new List<object>() },
|
||||
new RuleSubmissionListResponse { Success = true, Count = 0, Submissions = new List<RuleSubmissionItem>(), NextLink = null });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detail_shape_with_analyze_rule_omits_the_gather_slot()
|
||||
{
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
RuleId = "ANALYZE-CUSTOM-003", Title = "Proxy PAC unreachable", Description = "d", Severity = "warning", Category = "network",
|
||||
Explanation = "e", IsBuiltIn = false, Conditions = new List<RuleCondition>(),
|
||||
};
|
||||
var typed = new RuleSubmissionDetailResponse
|
||||
{
|
||||
Success = true,
|
||||
Submission = Item(),
|
||||
AnalyzeRule = rule,
|
||||
LiveFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 1, SessionsEvaluated = 2, EvaluationCount = 3 },
|
||||
SuggestedPublishedRuleId = "ANALYZE-NET-002",
|
||||
RepoFile = new RuleSubmissionRepoFile { Path = "rules/analyze/ANALYZE-NET-002.json", Content = "{}\n" },
|
||||
};
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
submission = ItemLiteral(),
|
||||
analyzeRule = rule,
|
||||
liveFireStats = new { days = 30, fireCount = 1, sessionsEvaluated = 2, evaluationCount = 3 },
|
||||
suggestedPublishedRuleId = "ANALYZE-NET-002",
|
||||
repoFile = new { path = "rules/analyze/ANALYZE-NET-002.json", content = "{}\n" },
|
||||
},
|
||||
typed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Review_response_shape()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, message = "Submission approved.", submission = ItemLiteral() },
|
||||
new ReviewRuleSubmissionResponse { Success = true, Message = "Submission approved.", Submission = Item() });
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,12 @@ public sealed class SharedManifestParityTests
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
|
||||
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
|
||||
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
|
||||
["ruleSubmissionStatuses"] = RuleSubmissionStatuses.All,
|
||||
["ruleAttributionModes"] = RuleAttributionModes.All,
|
||||
["ruleSubmissionDecisions"] = RuleSubmissionDecisions.All,
|
||||
// Ops vocabularies. Declaration order kept for severities — it IS the ladder
|
||||
// (Info < Warning < Error < Critical), and the MCP renders it in that order.
|
||||
["opsEventCategories"] = ConstStrings(typeof(OpsEventCategory)),
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip pins for the RuleSubmissions Store/Map pair (memory:
|
||||
/// feedback_table_storage_serialization — every new field in <see cref="RuleSubmission"/>
|
||||
/// MUST be exercised in Store+Map so silent drops surface here), plus the key and filter shapes.
|
||||
/// </summary>
|
||||
public class TableRuleSubmissionRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static RuleSubmission Full() => new()
|
||||
{
|
||||
SubmissionId = "a1b2c3d4e5f6",
|
||||
BatchId = "0f0e0d0c0b0a",
|
||||
TenantId = TenantId,
|
||||
RuleKind = RuleSubmissionKinds.Analyze,
|
||||
SourceRuleId = "ANALYZE-CUSTOM-003",
|
||||
Title = "Proxy PAC unreachable",
|
||||
Category = "network",
|
||||
RuleJson = "{\"ruleId\":\"ANALYZE-CUSTOM-003\",\"title\":\"Proxy PAC unreachable\"}",
|
||||
Comment = "Fires when the PAC download fails during ESP.",
|
||||
SubmittedBy = "alice@contoso.com",
|
||||
SubmittedByName = "Alice Admin",
|
||||
ContactEmail = "alice.reply@contoso.com",
|
||||
AttributionMode = RuleAttributionModes.Person,
|
||||
AttributionName = "Alice A.",
|
||||
SubmittedAt = new DateTime(2026, 9, 9, 10, 30, 0, DateTimeKind.Utc),
|
||||
Status = RuleSubmissionStatuses.Approved,
|
||||
ValidationFindings = new List<RuleSubmissionFinding>
|
||||
{
|
||||
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" },
|
||||
},
|
||||
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
|
||||
ReviewedBy = "ga@example.com",
|
||||
ReviewedAt = new DateTime(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc),
|
||||
ReviewComment = "Taken, generalised for the community.",
|
||||
WillBeAdapted = true,
|
||||
PublishedRuleId = "ANALYZE-NET-002",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_all_fields_survive_store_and_map()
|
||||
{
|
||||
var original = Full();
|
||||
|
||||
var mapped = TableRuleSubmissionRepository.MapSubmission(
|
||||
TableRuleSubmissionRepository.StoreSubmission(original));
|
||||
|
||||
Assert.Equal(original.SubmissionId, mapped.SubmissionId);
|
||||
Assert.Equal(original.BatchId, mapped.BatchId);
|
||||
Assert.Equal(original.TenantId, mapped.TenantId);
|
||||
Assert.Equal(original.RuleKind, mapped.RuleKind);
|
||||
Assert.Equal(original.SourceRuleId, mapped.SourceRuleId);
|
||||
Assert.Equal(original.Title, mapped.Title);
|
||||
Assert.Equal(original.Category, mapped.Category);
|
||||
Assert.Equal(original.RuleJson, mapped.RuleJson);
|
||||
Assert.Equal(original.Comment, mapped.Comment);
|
||||
Assert.Equal(original.SubmittedBy, mapped.SubmittedBy);
|
||||
Assert.Equal(original.SubmittedByName, mapped.SubmittedByName);
|
||||
Assert.Equal(original.ContactEmail, mapped.ContactEmail);
|
||||
Assert.Equal(original.AttributionMode, mapped.AttributionMode);
|
||||
Assert.Equal(original.AttributionName, mapped.AttributionName);
|
||||
Assert.Equal(original.SubmittedAt, mapped.SubmittedAt);
|
||||
Assert.Equal(original.Status, mapped.Status);
|
||||
Assert.Single(mapped.ValidationFindings);
|
||||
Assert.Equal("info", mapped.ValidationFindings[0].Level);
|
||||
Assert.Equal(original.ValidationFindings[0].Message, mapped.ValidationFindings[0].Message);
|
||||
Assert.NotNull(mapped.SourceFireStats);
|
||||
Assert.Equal(42, mapped.SourceFireStats!.FireCount);
|
||||
Assert.Equal(310, mapped.SourceFireStats.SessionsEvaluated);
|
||||
Assert.Equal(312, mapped.SourceFireStats.EvaluationCount);
|
||||
Assert.Equal(30, mapped.SourceFireStats.Days);
|
||||
Assert.Equal(original.ReviewedBy, mapped.ReviewedBy);
|
||||
Assert.Equal(original.ReviewedAt, mapped.ReviewedAt);
|
||||
Assert.Equal(original.ReviewComment, mapped.ReviewComment);
|
||||
Assert.True(mapped.WillBeAdapted);
|
||||
Assert.Equal(original.PublishedRuleId, mapped.PublishedRuleId);
|
||||
Assert.Equal(original.DerivedFromTemplateRuleId, mapped.DerivedFromTemplateRuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_pending_row_keeps_nulls_null()
|
||||
{
|
||||
var original = Full();
|
||||
original.Status = RuleSubmissionStatuses.Pending;
|
||||
original.Comment = null;
|
||||
original.ContactEmail = null;
|
||||
original.SourceFireStats = null;
|
||||
original.ValidationFindings = new List<RuleSubmissionFinding>();
|
||||
original.ReviewedBy = null;
|
||||
original.ReviewedAt = null;
|
||||
original.ReviewComment = null;
|
||||
original.WillBeAdapted = false;
|
||||
original.PublishedRuleId = null;
|
||||
original.DerivedFromTemplateRuleId = null;
|
||||
|
||||
var mapped = TableRuleSubmissionRepository.MapSubmission(
|
||||
TableRuleSubmissionRepository.StoreSubmission(original));
|
||||
|
||||
Assert.Null(mapped.Comment);
|
||||
Assert.Null(mapped.ContactEmail);
|
||||
Assert.Null(mapped.SourceFireStats);
|
||||
Assert.Empty(mapped.ValidationFindings);
|
||||
Assert.Null(mapped.ReviewedBy);
|
||||
Assert.Null(mapped.ReviewedAt);
|
||||
Assert.Null(mapped.ReviewComment);
|
||||
Assert.False(mapped.WillBeAdapted);
|
||||
Assert.Null(mapped.PublishedRuleId);
|
||||
Assert.Null(mapped.DerivedFromTemplateRuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_uses_the_single_partition_and_an_inverted_tick_row_key()
|
||||
{
|
||||
var s = Full();
|
||||
var entity = TableRuleSubmissionRepository.StoreSubmission(s);
|
||||
|
||||
Assert.Equal("submissions", entity.PartitionKey);
|
||||
Assert.Equal($"{RowKeyCodec.InvertedTicks(s.SubmittedAt)}_{s.SubmissionId}", entity.RowKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newer_submission_sorts_first()
|
||||
{
|
||||
var older = Full();
|
||||
var newer = Full();
|
||||
newer.SubmittedAt = older.SubmittedAt.AddMinutes(5);
|
||||
|
||||
var olderKey = TableRuleSubmissionRepository.StoreSubmission(older).RowKey;
|
||||
var newerKey = TableRuleSubmissionRepository.StoreSubmission(newer).RowKey;
|
||||
|
||||
Assert.True(string.CompareOrdinal(newerKey, olderKey) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_tolerates_a_corrupt_json_column()
|
||||
{
|
||||
var entity = TableRuleSubmissionRepository.StoreSubmission(Full());
|
||||
entity["ValidationFindingsJson"] = "{not json";
|
||||
entity["SourceFireStatsJson"] = "[]";
|
||||
|
||||
var mapped = TableRuleSubmissionRepository.MapSubmission(entity);
|
||||
|
||||
Assert.Empty(mapped.ValidationFindings);
|
||||
Assert.Null(mapped.SourceFireStats);
|
||||
}
|
||||
|
||||
// ── retention rule (shared by the sweep and this pin) ──
|
||||
|
||||
private static readonly DateTime Now = new(2026, 12, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData("withdrawn", 29, false)]
|
||||
[InlineData("withdrawn", 30, true)]
|
||||
[InlineData("declined", 89, false)]
|
||||
[InlineData("declined", 90, true)]
|
||||
[InlineData("pending", 400, false)]
|
||||
[InlineData("approved", 400, false)]
|
||||
public void Retention_expires_only_closed_rows_at_their_window(string status, int ageDays, bool expired)
|
||||
{
|
||||
var s = Full();
|
||||
s.Status = status;
|
||||
s.SubmittedAt = Now.AddDays(-ageDays);
|
||||
s.ReviewedAt = status == "declined" ? Now.AddDays(-ageDays) : null;
|
||||
|
||||
Assert.Equal(expired, RuleSubmissionRetention.IsExpired(s, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retention_of_a_declined_row_counts_from_the_decision_not_the_submission()
|
||||
{
|
||||
var s = Full();
|
||||
s.Status = RuleSubmissionStatuses.Declined;
|
||||
s.SubmittedAt = Now.AddDays(-200);
|
||||
s.ReviewedAt = Now.AddDays(-10);
|
||||
|
||||
Assert.False(RuleSubmissionRetention.IsExpired(s, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, "PartitionKey eq 'submissions'")]
|
||||
[InlineData("t'1", null, "PartitionKey eq 'submissions' and TenantId eq 't''1'")]
|
||||
[InlineData(null, "pending", "PartitionKey eq 'submissions' and Status eq 'pending'")]
|
||||
[InlineData("t1", "approved", "PartitionKey eq 'submissions' and TenantId eq 't1' and Status eq 'approved'")]
|
||||
public void Filter_escapes_and_composes(string? tenantId, string? status, string expected)
|
||||
{
|
||||
Assert.Equal(expected, TableRuleSubmissionRepository.BuildFilter(tenantId, status));
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ public class TypedContractCompletionWireParityTests
|
||||
totalInstalls = 4,
|
||||
totalSkipped = 1,
|
||||
totalUnmeasured = 1,
|
||||
totalIncomplete = 0,
|
||||
totalCollisionExcluded = 0,
|
||||
slowestApps = new[]
|
||||
{
|
||||
@@ -195,6 +196,7 @@ public class TypedContractCompletionWireParityTests
|
||||
skipped = 1,
|
||||
unmeasured = 1,
|
||||
failed = 1,
|
||||
incomplete = 0,
|
||||
failureRate = 33.3,
|
||||
avgDurationSeconds = 120.0,
|
||||
maxDurationSeconds = 240,
|
||||
@@ -225,6 +227,7 @@ public class TypedContractCompletionWireParityTests
|
||||
TotalInstalls = 4,
|
||||
TotalSkipped = 1,
|
||||
TotalUnmeasured = 1,
|
||||
TotalIncomplete = 0,
|
||||
TotalCollisionExcluded = 0,
|
||||
SlowestApps = new[]
|
||||
{
|
||||
@@ -236,6 +239,7 @@ public class TypedContractCompletionWireParityTests
|
||||
Skipped = 1,
|
||||
Unmeasured = 1,
|
||||
Failed = 1,
|
||||
Incomplete = 0,
|
||||
FailureRate = 33.3,
|
||||
AvgDurationSeconds = 120.0,
|
||||
MaxDurationSeconds = 240,
|
||||
@@ -684,6 +688,7 @@ public class TypedContractCompletionWireParityTests
|
||||
skipped = 0,
|
||||
unmeasured = 0,
|
||||
failed = 0,
|
||||
incomplete = 0,
|
||||
failureRate = 0.0,
|
||||
avgDurationSeconds = 0.0,
|
||||
p95DurationSeconds = 0,
|
||||
@@ -715,6 +720,7 @@ public class TypedContractCompletionWireParityTests
|
||||
Skipped = 0,
|
||||
Unmeasured = 0,
|
||||
Failed = 0,
|
||||
Incomplete = 0,
|
||||
FailureRate = 0.0,
|
||||
AvgDurationSeconds = 0.0,
|
||||
P95DurationSeconds = 0,
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<ISessionAnnotationRepository, TableSessionAnnotationRepository>();
|
||||
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
|
||||
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
|
||||
services.AddSingleton<IRuleSubmissionRepository, TableRuleSubmissionRepository>();
|
||||
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
|
||||
services.AddSingleton<ISlaTenantStatusRepository, TableSlaTenantStatusRepository>();
|
||||
// What's new notifier watermark (single platform row in the AdminConfiguration table).
|
||||
|
||||
+3
@@ -142,6 +142,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task ResolveEspBlockingForSessionAsync(string tenantId, string sessionId)
|
||||
=> _storage.ResolveEspBlockingForSessionAsync(tenantId, sessionId);
|
||||
|
||||
public Task<int> CloseOpenAppInstallsForSessionAsync(string tenantId, string sessionId)
|
||||
=> _storage.CloseOpenAppInstallsForSessionAsync(tenantId, sessionId);
|
||||
|
||||
public Task<bool> SaveTimeAttributionAggregateAsync(TimeAttributionDailyAggregate aggregate)
|
||||
=> _storage.SaveTimeAttributionAggregateAsync(aggregate);
|
||||
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage implementation of <see cref="IRuleSubmissionRepository"/>.
|
||||
/// PK = <see cref="PartitionKey"/> for every tenant, RK = inverted ticks + id (newest first).
|
||||
/// </summary>
|
||||
public class TableRuleSubmissionRepository : IRuleSubmissionRepository
|
||||
{
|
||||
internal const string PartitionKey = "submissions";
|
||||
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableRuleSubmissionRepository> _logger;
|
||||
|
||||
public TableRuleSubmissionRepository(TableStorageService storage, ILogger<TableRuleSubmissionRepository> logger)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.RuleSubmissions);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> AddAsync(RuleSubmission submission)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.AddEntityAsync(StoreSubmission(submission));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store rule submission {SubmissionId}", submission.SubmissionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RuleSubmission?> GetAsync(string submissionId)
|
||||
{
|
||||
var entity = await FindEntityAsync(submissionId);
|
||||
return entity == null ? null : MapSubmission(entity);
|
||||
}
|
||||
|
||||
public async Task<List<RuleSubmission>> GetForTenantAsync(string tenantId)
|
||||
{
|
||||
var results = new List<RuleSubmission>();
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: BuildFilter(tenantId, status: null)))
|
||||
{
|
||||
results.Add(MapSubmission(entity));
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("RuleSubmissions table does not exist yet, returning empty list");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<RawPage<RuleSubmission>> GetPageAsync(string? tenantId, string? status, int pageSize, string? continuation)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
{
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _tableClient,
|
||||
filter: BuildFilter(tenantId, status),
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var page = new List<RuleSubmission>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapSubmission(entity));
|
||||
// RowKey is the inverted tick of SubmittedAt — newest first by construction.
|
||||
return new RawPage<RuleSubmission>(page, nextRawToken);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("RuleSubmissions table does not exist yet, returning empty page");
|
||||
return RawPage<RuleSubmission>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(RuleSubmission submission)
|
||||
{
|
||||
var existing = await FindEntityAsync(submission.SubmissionId);
|
||||
if (existing == null)
|
||||
{
|
||||
_logger.LogWarning("UpdateAsync: rule submission {SubmissionId} not found", submission.SubmissionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var entity = StoreSubmission(submission);
|
||||
entity.RowKey = existing.RowKey;
|
||||
entity.ETag = existing.ETag;
|
||||
try
|
||||
{
|
||||
// If-Match on the ETag: two reviewers deciding the same submission cannot both win.
|
||||
await _tableClient.UpdateEntityAsync(entity, existing.ETag, TableUpdateMode.Replace);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
_logger.LogWarning("UpdateAsync: rule submission {SubmissionId} changed underneath the reviewer", submission.SubmissionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HashSet<string>> GetReservedPublishedRuleIdsAsync()
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(
|
||||
filter: BuildFilter(tenantId: null, status: RuleSubmissionStatuses.Approved),
|
||||
select: new[] { "PublishedRuleId" }))
|
||||
{
|
||||
var id = entity.GetString("PublishedRuleId");
|
||||
if (!string.IsNullOrEmpty(id)) ids.Add(id!);
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("RuleSubmissions table does not exist yet");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(DateTime nowUtc)
|
||||
{
|
||||
var deleted = 0;
|
||||
try
|
||||
{
|
||||
// The expiry rule is the shared RuleSubmissionRetention, so the sweep and its
|
||||
// test cannot disagree; only the two closed statuses are scanned.
|
||||
foreach (var status in new[] { RuleSubmissionStatuses.Withdrawn, RuleSubmissionStatuses.Declined })
|
||||
{
|
||||
var expired = new List<TableEntity>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: BuildFilter(tenantId: null, status)))
|
||||
{
|
||||
if (RuleSubmissionRetention.IsExpired(MapSubmission(entity), nowUtc)) expired.Add(entity);
|
||||
}
|
||||
foreach (var entity in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
|
||||
deleted++;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already gone — a parallel sweep or a tenant offboarding won the race.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("RuleSubmissions table does not exist yet");
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string submissionId)
|
||||
{
|
||||
var existing = await FindEntityAsync(submissionId);
|
||||
if (existing == null) return false;
|
||||
try
|
||||
{
|
||||
await _tableClient.DeleteEntityAsync(existing.PartitionKey, existing.RowKey);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableEntity?> FindEntityAsync(string submissionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(
|
||||
filter: $"PartitionKey eq '{PartitionKey}' and SubmissionId eq '{Escape(submissionId)}'"))
|
||||
{
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug("RuleSubmissions table does not exist yet");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string BuildFilter(string? tenantId, string? status)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{PartitionKey}'";
|
||||
if (!string.IsNullOrEmpty(tenantId)) filter += $" and TenantId eq '{Escape(tenantId!)}'";
|
||||
if (!string.IsNullOrEmpty(status)) filter += $" and Status eq '{Escape(status!)}'";
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value.Replace("'", "''");
|
||||
|
||||
// ── Store / Map ─────────────────────────────────────────────────────────
|
||||
// internal static: pinned by round-trip unit tests — every property must survive
|
||||
// Store→Map, so any new field lands in BOTH methods plus the test.
|
||||
|
||||
internal static TableEntity StoreSubmission(RuleSubmission s) =>
|
||||
new(PartitionKey, $"{RowKeyCodec.InvertedTicks(s.SubmittedAt)}_{s.SubmissionId}")
|
||||
{
|
||||
["SubmissionId"] = s.SubmissionId,
|
||||
["BatchId"] = s.BatchId,
|
||||
["TenantId"] = s.TenantId,
|
||||
["RuleKind"] = s.RuleKind,
|
||||
["SourceRuleId"] = s.SourceRuleId,
|
||||
["Title"] = s.Title,
|
||||
["Category"] = s.Category,
|
||||
["RuleJson"] = s.RuleJson,
|
||||
["Comment"] = s.Comment ?? string.Empty,
|
||||
["SubmittedBy"] = s.SubmittedBy,
|
||||
["SubmittedByName"] = s.SubmittedByName,
|
||||
["ContactEmail"] = s.ContactEmail ?? string.Empty,
|
||||
["AttributionMode"] = s.AttributionMode,
|
||||
["AttributionName"] = s.AttributionName,
|
||||
["SubmittedAt"] = s.SubmittedAt,
|
||||
["Status"] = s.Status,
|
||||
["ValidationFindingsJson"] = JsonSerializer.Serialize(s.ValidationFindings),
|
||||
["SourceFireStatsJson"] = s.SourceFireStats == null ? string.Empty : JsonSerializer.Serialize(s.SourceFireStats),
|
||||
["ReviewedBy"] = s.ReviewedBy ?? string.Empty,
|
||||
["ReviewedAt"] = s.ReviewedAt,
|
||||
["ReviewComment"] = s.ReviewComment ?? string.Empty,
|
||||
["WillBeAdapted"] = s.WillBeAdapted,
|
||||
["PublishedRuleId"] = s.PublishedRuleId ?? string.Empty,
|
||||
["DerivedFromTemplateRuleId"] = s.DerivedFromTemplateRuleId ?? string.Empty,
|
||||
};
|
||||
|
||||
internal static RuleSubmission MapSubmission(TableEntity e) => new()
|
||||
{
|
||||
SubmissionId = e.GetString("SubmissionId") ?? string.Empty,
|
||||
BatchId = e.GetString("BatchId") ?? string.Empty,
|
||||
TenantId = e.GetString("TenantId") ?? string.Empty,
|
||||
RuleKind = e.GetString("RuleKind") ?? string.Empty,
|
||||
SourceRuleId = e.GetString("SourceRuleId") ?? string.Empty,
|
||||
Title = e.GetString("Title") ?? string.Empty,
|
||||
Category = e.GetString("Category") ?? string.Empty,
|
||||
RuleJson = e.GetString("RuleJson") ?? string.Empty,
|
||||
Comment = NullIfEmpty(e.GetString("Comment")),
|
||||
SubmittedBy = e.GetString("SubmittedBy") ?? string.Empty,
|
||||
SubmittedByName = e.GetString("SubmittedByName") ?? string.Empty,
|
||||
ContactEmail = NullIfEmpty(e.GetString("ContactEmail")),
|
||||
AttributionMode = e.GetString("AttributionMode") ?? RuleAttributionModes.Anonymous,
|
||||
AttributionName = e.GetString("AttributionName") ?? RuleAttributionModes.AnonymousAuthor,
|
||||
SubmittedAt = e.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
Status = e.GetString("Status") ?? RuleSubmissionStatuses.Pending,
|
||||
ValidationFindings = DeserializeOrDefault<List<RuleSubmissionFinding>>(e.GetString("ValidationFindingsJson")) ?? new List<RuleSubmissionFinding>(),
|
||||
SourceFireStats = DeserializeOrDefault<RuleSubmissionFireStats>(e.GetString("SourceFireStatsJson")),
|
||||
ReviewedBy = NullIfEmpty(e.GetString("ReviewedBy")),
|
||||
ReviewedAt = e.GetDateTimeOffset("ReviewedAt")?.UtcDateTime,
|
||||
ReviewComment = NullIfEmpty(e.GetString("ReviewComment")),
|
||||
WillBeAdapted = e.GetBoolean("WillBeAdapted") ?? false,
|
||||
PublishedRuleId = NullIfEmpty(e.GetString("PublishedRuleId")),
|
||||
DerivedFromTemplateRuleId = NullIfEmpty(e.GetString("DerivedFromTemplateRuleId")),
|
||||
};
|
||||
|
||||
private static T? DeserializeOrDefault<T>(string? json) where T : class
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json!);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A corrupt column must not hide the submission itself.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
}
|
||||
+7
@@ -101,6 +101,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// complete. Positive evidence only (listed ⇒ EspBlocking=true, absent ⇒ stays
|
||||
// unknown). Idempotent + fail-soft like the counter reconcile above.
|
||||
await _storage.ResolveEspBlockingForSessionAsync(tenantId, sessionId);
|
||||
// Observation end: app rows still InProgress on a terminal session never get a
|
||||
// terminal event — close them as Incomplete (outcome unknown, not a failure).
|
||||
// Early for the ingest path (verdict batch, the agent may still be watching),
|
||||
// never wrong in the end state: a real terminal arriving later still wins via
|
||||
// the sticky-status reconcile, and the shutdown batch re-runs the close for rows
|
||||
// opened after the verdict. Idempotent + fail-soft.
|
||||
await _storage.CloseOpenAppInstallsForSessionAsync(tenantId, sessionId);
|
||||
|
||||
// F1 PR2: compute + persist the time-attribution breakdown once, now that the
|
||||
// terminal write stamped CompletedAt/DurationSeconds and the event stream is
|
||||
|
||||
@@ -291,6 +291,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var measured = installed.Where(MetricsMath.HasMeasuredDuration).ToList();
|
||||
var succeeded = installed.Count;
|
||||
var failed = summaries.Count(s => s.Status == "Failed");
|
||||
var incomplete = summaries.Count(s => s.Status == "Incomplete");
|
||||
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
|
||||
var avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0;
|
||||
var p95DurationSeconds = Percentile(measured.Select(s => s.DurationSeconds).ToList(), 0.95);
|
||||
@@ -423,6 +424,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
Skipped = skipped,
|
||||
Unmeasured = installed.Count - measured.Count,
|
||||
Failed = failed,
|
||||
Incomplete = incomplete,
|
||||
FailureRate = failureRate,
|
||||
AvgDurationSeconds = avgDurationSeconds,
|
||||
P95DurationSeconds = p95DurationSeconds,
|
||||
|
||||
@@ -115,6 +115,29 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.UnauthorizedAsync("Unable to determine tenant identity");
|
||||
}
|
||||
|
||||
return await OrganizationUsageResponseAsync(req, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/organization?tenantId=&dateFrom=&dateTo= — the same organization
|
||||
/// breakdown for ONE tenant named by a Global Admin / Global Reader (the tenant MCP Usage page under a tenant
|
||||
/// override). There is no aggregate path: tenantId is required. Catalogued with TenantScoping.None on
|
||||
/// purpose — the route enumerates a tenant's accounts, which a delegated (MSP) caller must never reach.
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpOrganizationUsage")]
|
||||
public async Task<HttpResponseData> GetGlobalOrganizationUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/organization")] HttpRequestData req)
|
||||
{
|
||||
if (!Guid.TryParse(req.Query["tenantId"], out var tenantGuid))
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId is required");
|
||||
}
|
||||
|
||||
return await OrganizationUsageResponseAsync(req, tenantGuid.ToString());
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> OrganizationUsageResponseAsync(HttpRequestData req, string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
@@ -127,6 +150,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var readFrom = string.CompareOrdinal(dateFrom, monthStart) < 0 ? dateFrom : monthStart;
|
||||
var readTo = string.CompareOrdinal(dateTo, today) > 0 ? dateTo : today;
|
||||
var records = await _userUsageRepo.GetTenantUsageAsync(tenantId, readFrom, readTo);
|
||||
var limits = await _quotaService.ResolveTenantPlanAsync(tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetMcpOrganizationUsageResponse
|
||||
@@ -135,6 +159,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
Users = AggregateOrganizationUsage(records, tenantId, dateFrom, dateTo, today, monthStart),
|
||||
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -144,6 +170,46 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure: requests charged to the tenant per day inside [dateFrom, dateTo], oldest first.</summary>
|
||||
internal static List<McpOrganizationDailyItem> BuildOrganizationDaily(
|
||||
IEnumerable<TenantUsageRecord> records, string dateFrom, string dateTo)
|
||||
{
|
||||
var byDay = new SortedDictionary<string, long>(StringComparer.Ordinal);
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (string.CompareOrdinal(record.Date, dateFrom) < 0 || string.CompareOrdinal(record.Date, dateTo) > 0)
|
||||
continue;
|
||||
byDay[record.Date] = byDay.TryGetValue(record.Date, out var sum) ? sum + record.RequestCount : record.RequestCount;
|
||||
}
|
||||
|
||||
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
|
||||
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds the organization counter rows into one item per account. A row whose HomeTenantId is set
|
||||
/// and differs from the tenant was charged by a delegated (MSP) read. Ordered by this month's usage.
|
||||
|
||||
@@ -55,6 +55,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return await req.BadRequestAsync("Invalid rule data");
|
||||
}
|
||||
|
||||
// Author is stamped from the creator's token, never from the payload
|
||||
// (anti-spoof), and stays immutable through every later update.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
@@ -88,6 +92,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
rule.RuleId = ruleId;
|
||||
|
||||
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
|
||||
// this with the original author (immutable attribution), but a full-payload PUT
|
||||
// that upserts a rule with no existing row must not store the payload's author.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
@@ -124,7 +133,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
try
|
||||
{
|
||||
var newRule = await _ruleService.CreateFromTemplateAsync(tenantId, ruleId, variables);
|
||||
var author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
var newRule = await _ruleService.CreateFromTemplateAsync(tenantId, ruleId, variables, author);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new CreateAnalyzeRuleFromTemplateResponse { Success = true, Rule = newRule, Message = "Custom rule created from template" });
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>
|
||||
/// Operator hard delete of a submission (test/demo rows, or a tenant's request). The row
|
||||
/// vanishes from both lists; a published rule in the repo is untouched.
|
||||
/// </summary>
|
||||
public class DeleteGlobalRuleSubmissionFunction
|
||||
{
|
||||
private readonly ILogger<DeleteGlobalRuleSubmissionFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public DeleteGlobalRuleSubmissionFunction(ILogger<DeleteGlobalRuleSubmissionFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("DeleteGlobalRuleSubmission")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rule-submissions/{submissionId}")] HttpRequestData req,
|
||||
string submissionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var user = TenantHelper.GetUserIdentifier(req);
|
||||
if (string.IsNullOrWhiteSpace(submissionId)) return await req.BadRequestAsync("submissionId is required.");
|
||||
|
||||
var existing = await _service.GetAsync(submissionId);
|
||||
if (existing == null) return await req.NotFoundAsync("Submission not found.");
|
||||
|
||||
if (!await _service.DeleteAsync(submissionId)) return await req.NotFoundAsync("Submission not found.");
|
||||
|
||||
// Warning on purpose: an operator delete is rare and must be visible in App Insights.
|
||||
_logger.LogWarning("Rule submission {SubmissionId} ({RuleId}, tenant {TenantId}, status {Status}) deleted by {User}",
|
||||
submissionId, existing.SourceRuleId, existing.TenantId, existing.Status, user);
|
||||
return await req.OkAsync(new SuccessMessageResponse { Success = true, Message = "Submission deleted." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "DeleteGlobalRuleSubmission");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>One submission with the frozen rule, live fire stats, the suggested id and the repo file.</summary>
|
||||
public class GetGlobalRuleSubmissionFunction
|
||||
{
|
||||
private readonly ILogger<GetGlobalRuleSubmissionFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public GetGlobalRuleSubmissionFunction(ILogger<GetGlobalRuleSubmissionFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("GetGlobalRuleSubmission")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/rule-submissions/{submissionId}")] HttpRequestData req,
|
||||
string submissionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
if (string.IsNullOrWhiteSpace(submissionId)) return await req.BadRequestAsync("submissionId is required.");
|
||||
|
||||
var detail = await _service.GetDetailAsync(submissionId);
|
||||
if (detail == null) return await req.NotFoundAsync("Submission not found.");
|
||||
return await req.OkAsync(detail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalRuleSubmission");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>Operator list across tenants: <c>?tenantId=</c> and <c>?status=</c> are server-side filters, paging as for session reports.</summary>
|
||||
public class GetGlobalRuleSubmissionsFunction
|
||||
{
|
||||
private readonly ILogger<GetGlobalRuleSubmissionsFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public GetGlobalRuleSubmissionsFunction(ILogger<GetGlobalRuleSubmissionsFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("GetGlobalRuleSubmissions")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/rule-submissions")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var parsed = RuleSubmissionsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null) return await req.BadRequestAsync(parsed.Error);
|
||||
|
||||
var pageSize = parsed.PageSize ?? RuleSubmissionsPagination.DefaultPageSize;
|
||||
|
||||
string? azureToken = null;
|
||||
if (parsed.Continuation != null)
|
||||
{
|
||||
if (!RuleSubmissionsPagination.TryAcceptContinuation(
|
||||
parsed.Continuation, callerTenantId, parsed.FilterTenantId, parsed.FilterStatus,
|
||||
out azureToken, out var rejectReason))
|
||||
{
|
||||
_logger.LogWarning("GetGlobalRuleSubmissions: continuation rejected ({Reason})", rejectReason);
|
||||
return await req.BadRequestAsync($"Invalid continuation token ({rejectReason}). Restart pagination from the first page.");
|
||||
}
|
||||
}
|
||||
|
||||
var page = await _service.GetPageAsync(parsed.FilterTenantId, parsed.FilterStatus, pageSize, azureToken);
|
||||
var items = await _service.ToItemsAsync(page.Items);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(page.NextRawToken))
|
||||
{
|
||||
var fp = RuleSubmissionsPagination.Fingerprint(callerTenantId, parsed.FilterTenantId, parsed.FilterStatus);
|
||||
var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
|
||||
nextLink = RuleSubmissionsPagination.BuildNextLink(pageSize, wireToken, parsed.FilterTenantId, parsed.FilterStatus);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new RuleSubmissionListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Submissions = items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalRuleSubmissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>The submitting tenant's own submissions, newest first, with the effective status.</summary>
|
||||
public class GetRuleSubmissionsFunction
|
||||
{
|
||||
private readonly ILogger<GetRuleSubmissionsFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public GetRuleSubmissionsFunction(ILogger<GetRuleSubmissionsFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("GetRuleSubmissions")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "rules/submissions")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Always the JWT tenant — a GA reads foreign tenants through global/rule-submissions?tenantId=.
|
||||
var tenantId = req.GetRequestContext().TenantId;
|
||||
var rows = await _service.GetForTenantAsync(tenantId);
|
||||
var items = await _service.ToItemsAsync(rows);
|
||||
return await req.OkAsync(new RuleSubmissionListResponse { Success = true, Count = items.Count, Submissions = items });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetRuleSubmissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>The reviewer's decision (portal or MCP). The submitter is told through a tenant notification.</summary>
|
||||
public class ReviewRuleSubmissionFunction
|
||||
{
|
||||
private readonly ILogger<ReviewRuleSubmissionFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
private readonly TenantNotificationService _tenantNotifications;
|
||||
|
||||
public ReviewRuleSubmissionFunction(
|
||||
ILogger<ReviewRuleSubmissionFunction> logger,
|
||||
RuleSubmissionService service,
|
||||
TenantNotificationService tenantNotifications)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_tenantNotifications = tenantNotifications;
|
||||
}
|
||||
|
||||
[Function("ReviewRuleSubmission")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/rule-submissions/{submissionId}")] HttpRequestData req,
|
||||
string submissionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var reviewer = TenantHelper.GetUserIdentifier(req);
|
||||
if (string.IsNullOrWhiteSpace(submissionId)) return await req.BadRequestAsync("submissionId is required.");
|
||||
|
||||
var read = await req.ReadAsync<ReviewRuleSubmissionRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
RuleSubmission updated;
|
||||
try
|
||||
{
|
||||
updated = await _service.ReviewAsync(submissionId, read.Value!, reviewer);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return await req.NotFoundAsync(ex.Message);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return await req.ConflictAsync(ex.Message);
|
||||
}
|
||||
|
||||
var approved = updated.Status == RuleSubmissionStatuses.Approved;
|
||||
var title = approved
|
||||
? $"Rule submission approved: {updated.SourceRuleId}"
|
||||
: $"Rule submission declined: {updated.SourceRuleId}";
|
||||
var message = approved
|
||||
? (updated.WillBeAdapted
|
||||
? $"\"{updated.Title}\" will be published as {updated.PublishedRuleId} after a small adaptation for the community."
|
||||
: $"\"{updated.Title}\" will be published as {updated.PublishedRuleId}.")
|
||||
: $"\"{updated.Title}\" was not accepted for the community pool.";
|
||||
if (!string.IsNullOrEmpty(updated.ReviewComment)) message += $" Reviewer: {updated.ReviewComment}";
|
||||
|
||||
// Best effort; the submissions list carries the same facts.
|
||||
_ = _tenantNotifications.CreateNotificationAsync(
|
||||
updated.TenantId, "rule_submission_decided", title, message,
|
||||
href: updated.RuleKind == RuleSubmissionKinds.Analyze ? "/analyze-rules" : "/gather-rules");
|
||||
|
||||
_logger.LogInformation("Rule submission {SubmissionId} {Status} by {Reviewer} (published id {PublishedRuleId})",
|
||||
submissionId, updated.Status, reviewer, updated.PublishedRuleId ?? "-");
|
||||
|
||||
var item = (await _service.ToItemsAsync(new[] { updated }))[0];
|
||||
return await req.OkAsync(new ReviewRuleSubmissionResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = approved ? "Submission approved." : "Submission declined.",
|
||||
Submission = item,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "ReviewRuleSubmission");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>
|
||||
/// A tenant admin submits one or more of the tenant's custom rules for the community pool.
|
||||
/// The body carries only rule references; the service freezes the rules server-side.
|
||||
/// </summary>
|
||||
public class SubmitRuleSubmissionsFunction
|
||||
{
|
||||
private readonly ILogger<SubmitRuleSubmissionsFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegram;
|
||||
private readonly GlobalNotificationService _globalNotifications;
|
||||
|
||||
public SubmitRuleSubmissionsFunction(
|
||||
ILogger<SubmitRuleSubmissionsFunction> logger,
|
||||
RuleSubmissionService service,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegram,
|
||||
GlobalNotificationService globalNotifications)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegram = telegram;
|
||||
_globalNotifications = globalNotifications;
|
||||
}
|
||||
|
||||
[Function("SubmitRuleSubmissions")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/submissions")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var tenantId = requestCtx.TenantId;
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
var read = await req.ReadAsync<SubmitRuleSubmissionsRequest>(262_144);
|
||||
if (read.Error != null) return read.Error;
|
||||
var request = read.Value!;
|
||||
|
||||
// Same tenant-tamper gate as the session report: non-GAs submit for their JWT
|
||||
// tenant only; a GA may submit on behalf of a foreign tenant and must name it.
|
||||
if (!requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.TenantId)
|
||||
&& !string.Equals(request.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SubmitRuleSubmissions: BLOCKED cross-tenant body for non-GA user={User} jwtTenant={JwtTenant} bodyTenant={BodyTenant}",
|
||||
userIdentifier, tenantId, request.TenantId);
|
||||
return await req.ForbiddenAsync("Body tenantId must match your authenticated tenant.");
|
||||
}
|
||||
request.TenantId = tenantId;
|
||||
}
|
||||
else if (string.IsNullOrEmpty(request.TenantId))
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId is required in body for Global Admin submissions.");
|
||||
}
|
||||
|
||||
if (!SecurityValidator.IsValidGuid(request.TenantId))
|
||||
return await req.BadRequestAsync("Invalid tenantId.");
|
||||
|
||||
var displayName = TenantHelper.GetUserDisplayName(req) ?? userIdentifier;
|
||||
List<RuleSubmission> created;
|
||||
try
|
||||
{
|
||||
created = await _service.SubmitAsync(request.TenantId, request, userIdentifier, displayName);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return await req.ConflictAsync(ex.Message);
|
||||
}
|
||||
|
||||
if (!requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
foreach (var s in created)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
request.TenantId, "CREATE", "RuleSubmission", s.SubmissionId, userIdentifier,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Action", "SubmitRuleSubmission" },
|
||||
{ "RuleKind", s.RuleKind },
|
||||
{ "RuleId", s.SourceRuleId },
|
||||
{ "BatchId", s.BatchId },
|
||||
{ "AttributionMode", s.AttributionMode },
|
||||
{ "HasComment", (s.Comment != null).ToString() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Operator notifications — best effort, one per rule so every id has its own deep link.
|
||||
foreach (var s in created)
|
||||
{
|
||||
_ = _telegram.SendRuleSubmissionAsync(s.SubmissionId, request.TenantId, userIdentifier, s.RuleKind, s.SourceRuleId, s.Title, s.Comment ?? string.Empty);
|
||||
_ = _globalNotifications.CreateNotificationAsync(
|
||||
"rule_submission",
|
||||
$"Rule submission {s.SubmissionId}",
|
||||
$"{s.SourceRuleId} \"{s.Title}\" ({s.RuleKind}) — {userIdentifier} (Tenant: {request.TenantId})",
|
||||
href: $"/admin/reports/rule-submissions?submissionId={Uri.EscapeDataString(s.SubmissionId)}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Rule submissions stored: {Count} rule(s), batch {BatchId}, tenant {TenantId}, by {User}",
|
||||
created.Count, created[0].BatchId, request.TenantId, userIdentifier);
|
||||
|
||||
var items = await _service.ToItemsAsync(created);
|
||||
return await req.OkAsync(new SubmitRuleSubmissionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = created.Count == 1 ? "Rule submitted for review." : $"{created.Count} rules submitted for review.",
|
||||
BatchId = created[0].BatchId,
|
||||
Submissions = items,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "SubmitRuleSubmissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
{
|
||||
/// <summary>The submitter takes a pending submission back. The row stays (status withdrawn) for the audit trail.</summary>
|
||||
public class WithdrawRuleSubmissionFunction
|
||||
{
|
||||
private readonly ILogger<WithdrawRuleSubmissionFunction> _logger;
|
||||
private readonly RuleSubmissionService _service;
|
||||
|
||||
public WithdrawRuleSubmissionFunction(ILogger<WithdrawRuleSubmissionFunction> logger, RuleSubmissionService service)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[Function("WithdrawRuleSubmission")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "rules/submissions/{submissionId}")] HttpRequestData req,
|
||||
string submissionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
if (string.IsNullOrWhiteSpace(submissionId)) return await req.BadRequestAsync("submissionId is required.");
|
||||
|
||||
bool withdrawn;
|
||||
try
|
||||
{
|
||||
// A foreign tenant's submission answers 404 for a non-GA — the row's existence is not theirs to learn.
|
||||
withdrawn = await _service.WithdrawAsync(requestCtx.TenantId, submissionId, crossTenantAllowed: requestCtx.IsGlobalAdmin);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return await req.ConflictAsync(ex.Message);
|
||||
}
|
||||
|
||||
if (!withdrawn) return await req.NotFoundAsync("Submission not found.");
|
||||
|
||||
_logger.LogInformation("Rule submission {SubmissionId} withdrawn by {User}", submissionId, requestCtx.UserPrincipalName);
|
||||
return await req.OkAsync(new SuccessMessageResponse { Success = true, Message = "Submission withdrawn." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "WithdrawRuleSubmission");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ public static class MetricsMath
|
||||
/// <summary>
|
||||
/// App-install failure rate over finished installs only: Failed / (Failed + Succeeded), one
|
||||
/// decimal, 0 when nothing finished. The same outcome-quota convention as the enrollment
|
||||
/// success rate — "InProgress" rows (still installing, or orphaned by a session that died
|
||||
/// mid-install) never dilute the rate. Shared by the app-metrics payload and the Apps
|
||||
/// success rate — "InProgress" rows (still installing) and "Incomplete" rows (observation
|
||||
/// ended before any outcome) never dilute the rate. Shared by the app-metrics payload and the Apps
|
||||
/// dashboard aggregations so the definition can't drift between panes.
|
||||
/// </summary>
|
||||
public static double TerminalFailureRatePct(int failed, int succeeded)
|
||||
@@ -94,6 +94,8 @@ public static class MetricsMath
|
||||
var installed = succeededAll.Where(s => !IsSkipTerminalState(s)).ToList();
|
||||
var measured = installed.Where(HasMeasuredDuration).ToList();
|
||||
var failed = g.Where(s => s.Status == "Failed").ToList();
|
||||
// Observation ended before any outcome — disclosed, outside the rate and the durations.
|
||||
var incomplete = g.Count(s => s.Status == "Incomplete");
|
||||
var total = g.Count();
|
||||
|
||||
// DoAggregator is the single source for the DO rollup: it filters rows that actually
|
||||
@@ -109,6 +111,7 @@ public static class MetricsMath
|
||||
Skipped = skipped.Count,
|
||||
Unmeasured = installed.Count - measured.Count,
|
||||
Failed = failed.Count,
|
||||
Incomplete = incomplete,
|
||||
// Skips leave the rate (they are not attempts); legacy rows stay in it.
|
||||
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
||||
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||
@@ -151,6 +154,7 @@ public static class MetricsMath
|
||||
TotalInstalls = summaryList.Count,
|
||||
TotalSkipped = appGroups.Sum(a => a.Skipped),
|
||||
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
|
||||
TotalIncomplete = appGroups.Sum(a => a.Incomplete),
|
||||
TotalCollisionExcluded = totalCollisionExcluded,
|
||||
SlowestApps = slowestApps,
|
||||
TopFailingApps = topFailingApps,
|
||||
|
||||
@@ -30,6 +30,9 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
// agent/register-session + agent/upload-url (tenant from body), bootstrap/validate/{code},
|
||||
// bootstrap/sessions{,/{code}}, bootstrap/config, bootstrap/register-session — where a client
|
||||
// could send X-Tenant-Id:<valid-guid> and pollute tenant attribution in telemetry.
|
||||
// Those routes get their TenantId from RequestRowMarkers.ValidatedTenantKey instead, which
|
||||
// SecurityValidator stamps only once the tenant is proven (see ResolveTenantId). The list
|
||||
// stays for the routes that never reach the validator (distress is pre-auth).
|
||||
private static readonly HashSet<string> TenantHeaderTrustedPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/api/agent/telemetry",
|
||||
@@ -43,6 +46,35 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tenant attribution for the request row, most trusted source first: the policy pipeline's
|
||||
/// tenant (portal / MCP routes), then the tenant <c>SecurityValidator</c> proved for an agent
|
||||
/// request (<see cref="RequestRowMarkers.ValidatedTenantKey"/> — the only source for
|
||||
/// register / config / upload-url, which validate no tenant header), and last the
|
||||
/// <c>X-Tenant-Id</c> header on the exact routes that validate it themselves
|
||||
/// (<see cref="TenantHeaderTrustedPaths"/>), when it is a well-formed GUID. Any other route
|
||||
/// gets no tenant: an anonymous caller must not be able to pollute the attribution.
|
||||
/// </summary>
|
||||
internal static string? ResolveTenantId(
|
||||
string? policyTenantId,
|
||||
IDictionary<object, object> items,
|
||||
string path,
|
||||
string? headerTenantId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(policyTenantId))
|
||||
return policyTenantId;
|
||||
|
||||
if (items.TryGetValue(RequestRowMarkers.ValidatedTenantKey, out var validated)
|
||||
&& validated is string validatedTenantId && !string.IsNullOrEmpty(validatedTenantId))
|
||||
return validatedTenantId;
|
||||
|
||||
if (TenantHeaderTrustedPaths.Contains(path)
|
||||
&& !string.IsNullOrEmpty(headerTenantId) && Guid.TryParse(headerTenantId, out _))
|
||||
return headerTenantId;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
@@ -154,23 +186,17 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
if (context.Items.TryGetValue(DeviceIdentityBinding.RequestItemKey, out var identityBinding) && identityBinding is string identityOutcome)
|
||||
requestTelemetry.Properties[DeviceIdentityBinding.RequestItemKey] = identityOutcome;
|
||||
|
||||
// DEVICE-VALIDATION — the admitting validator (or None / Transient / Rejected), set by
|
||||
// SecurityValidator once the device-validation chain has run. Same carrier and reasoning.
|
||||
if (context.Items.TryGetValue(RequestRowMarkers.DeviceValidationKey, out var deviceValidation) && deviceValidation is string deviceValidationOutcome)
|
||||
requestTelemetry.Properties[RequestRowMarkers.DeviceValidationKey] = deviceValidationOutcome;
|
||||
|
||||
var reqCtx = context.GetRequestContext();
|
||||
var tenantId = reqCtx.TenantId;
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
// Device ingest endpoints do not run through PolicyEnforcementMiddleware, so
|
||||
// reqCtx.TenantId is empty — but they carry a cert/token-validated tenant in the
|
||||
// X-Tenant-Id header. Only honor the header on the exact routes that actually
|
||||
// validate it (TenantHeaderTrustedPaths) and only when it is a well-formed GUID, so
|
||||
// other/anonymous routes cannot pollute the requests table with arbitrary tenant ids.
|
||||
var path = httpContext.Request.Path.Value ?? string.Empty;
|
||||
if (TenantHeaderTrustedPaths.Contains(path))
|
||||
{
|
||||
var headerTenant = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(headerTenant) && Guid.TryParse(headerTenant, out _))
|
||||
tenantId = headerTenant;
|
||||
}
|
||||
}
|
||||
var tenantId = ResolveTenantId(
|
||||
reqCtx.TenantId,
|
||||
context.Items,
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
requestTelemetry.Properties["TenantId"] = tenantId;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>GET /api/global/rule-submissions</c> pagination surface — the
|
||||
/// SessionReportsPagination contract with a second server-side filter (<c>status</c>). Both
|
||||
/// filters are bound into the continuation fingerprint so a token issued for one view cannot
|
||||
/// resume another.
|
||||
/// </summary>
|
||||
public static class RuleSubmissionsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
|
||||
public static string Fingerprint(string callerTenantId, string? filterTenantId, string? filterStatus) =>
|
||||
ContinuationToken.ComputeFingerprint(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("scope", "rule-submissions"),
|
||||
new KeyValuePair<string, string?>("tenantId", callerTenantId),
|
||||
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
|
||||
new KeyValuePair<string, string?>("filterStatus", filterStatus),
|
||||
});
|
||||
|
||||
public sealed class Parsed
|
||||
{
|
||||
public string? FilterTenantId { get; init; }
|
||||
public string? FilterStatus { get; init; }
|
||||
public int? PageSize { get; init; }
|
||||
public string? Continuation { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public static Parsed ParseQuery(NameValueCollection? query)
|
||||
{
|
||||
var filterTenantIdRaw = query?["tenantId"];
|
||||
var filterStatusRaw = query?["status"];
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
|
||||
if (!string.IsNullOrEmpty(filterStatusRaw)
|
||||
&& System.Array.IndexOf(Shared.Models.RuleSubmissionStatuses.Stored, filterStatusRaw) < 0)
|
||||
{
|
||||
return new Parsed { Error = $"status must be one of: {string.Join(", ", Shared.Models.RuleSubmissionStatuses.Stored)}" };
|
||||
}
|
||||
|
||||
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
|
||||
? continuationRaw
|
||||
: null;
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
FilterTenantId = string.IsNullOrEmpty(filterTenantIdRaw) ? null : filterTenantIdRaw,
|
||||
FilterStatus = string.IsNullOrEmpty(filterStatusRaw) ? null : filterStatusRaw,
|
||||
PageSize = pageSize,
|
||||
Continuation = continuation,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryAcceptContinuation(
|
||||
string raw,
|
||||
string callerTenantId,
|
||||
string? filterTenantId,
|
||||
string? filterStatus,
|
||||
out string azureToken,
|
||||
out string? rejectReason)
|
||||
{
|
||||
var fp = Fingerprint(callerTenantId, filterTenantId, filterStatus);
|
||||
return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason);
|
||||
}
|
||||
|
||||
public static string BuildNextLink(int pageSize, string wireContinuation, string? filterTenantId, string? filterStatus)
|
||||
{
|
||||
var sb = new StringBuilder("/api/global/rule-submissions");
|
||||
sb.Append('?');
|
||||
sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation));
|
||||
if (!string.IsNullOrEmpty(filterTenantId))
|
||||
sb.Append("&tenantId=").Append(System.Uri.EscapeDataString(filterTenantId!));
|
||||
if (!string.IsNullOrEmpty(filterStatus))
|
||||
sb.Append("&status=").Append(System.Uri.EscapeDataString(filterStatus!));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.Di
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobCascadeDeleter>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.SessionReportDiagnosticsArchiveCopier>();
|
||||
builder.Services.AddSingleton<SessionReportService>();
|
||||
builder.Services.AddSingleton<RuleSubmissionService>();
|
||||
builder.Services.AddSingleton<BootstrapSessionService>();
|
||||
|
||||
// Queue producers + handlers. Consumers are [QueueTrigger] functions under Functions/Queue/
|
||||
|
||||
@@ -354,6 +354,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// troubleshooting action, not a config change — a support-driven Operator may use it.
|
||||
// Viewer and the read-only Global Reader stay excluded (the tier admits no Viewer/Reader).
|
||||
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrOperator),
|
||||
// Community rule submissions: the tenant admin submits references to own custom rules
|
||||
// (frozen server-side), lists and withdraws them for the JWT tenant only.
|
||||
new("POST", "rules/submissions", EndpointPolicy.TenantAdminOrGA),
|
||||
new("GET", "rules/submissions", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/submissions/{submissionId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
|
||||
new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
@@ -507,12 +512,20 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// One tenant's organization usage by account (?tenantId= read by the handler): deliberately None, not
|
||||
// QueryParam — the route enumerates accounts (UPNs), which the delegated path must never reach.
|
||||
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// Mints the HMAC ticket for the anonymous proxied stream above (the MCP's report download).
|
||||
new("POST", "global/session-reports/download-ticket", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
|
||||
// Community rule submissions, operator side: cross-tenant read (Global Reader included), decision GA-only.
|
||||
new("GET", "global/rule-submissions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PATCH", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly),
|
||||
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
|
||||
new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Request-row dimensions the agent security gate stamps via <c>FunctionContext.Items</c>;
|
||||
/// <c>RequestTelemetryMiddleware</c> copies them onto the <c>requests</c> row it emits per call.
|
||||
/// Same carrier as <see cref="CertTenantBinding.RequestItemKey"/>: worker-side
|
||||
/// <c>LogInformation</c> never reaches App Insights, so the accepting path would otherwise be
|
||||
/// invisible — only its failures would leave a trace.
|
||||
/// </summary>
|
||||
public static class RequestRowMarkers
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant the request was proven to belong to (certificate tenant binding matched, or
|
||||
/// the bootstrap token belongs to it). Agent routes that take the tenant from the body or
|
||||
/// the query string carry no trusted header, so this is the only safe source for the
|
||||
/// <c>TenantId</c> dimension on register / config / upload-url rows.
|
||||
/// </summary>
|
||||
public const string ValidatedTenantKey = "ValidatedTenantId";
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the device-validation chain: the admitting <see cref="ValidatorType"/> name,
|
||||
/// or one of <see cref="DeviceValidation"/>. Absent when an earlier stage (certificate,
|
||||
/// certificate tenant, hardware, rate limit) ended the request — the chain never ran.
|
||||
/// </summary>
|
||||
public const string DeviceValidationKey = "DeviceValidation";
|
||||
|
||||
/// <summary>Closed set of non-validator values, so <c>summarize by</c> stays stable.</summary>
|
||||
public static class DeviceValidation
|
||||
{
|
||||
/// <summary>Tenant runs without a device validator (<c>AllowInsecureAgentRequests</c>).</summary>
|
||||
public const string None = "None";
|
||||
/// <summary>Every enabled validator failed transiently — the agent got 503 Retry-After.</summary>
|
||||
public const string Transient = "Transient";
|
||||
/// <summary>Every enabled validator missed definitively — the agent got 403.</summary>
|
||||
public const string Rejected = "Rejected";
|
||||
}
|
||||
|
||||
public static string DeviceValidationValue(ValidatorType validatedBy)
|
||||
=> validatedBy == ValidatorType.Unknown ? DeviceValidation.None : validatedBy.ToString();
|
||||
|
||||
public static void Stamp(HttpRequestData req, string key, string value)
|
||||
{
|
||||
var items = req.FunctionContext?.Items;
|
||||
if (items != null)
|
||||
items[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
||||
RequestRowMarkers.DeviceValidationValue(ValidatorType.Bootstrap));
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
@@ -350,6 +353,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// From here on the tenant is proven (known, not suspended, certificate issued to it).
|
||||
// Register / config / upload-url take the tenant from body or query, not from a
|
||||
// validated header, so this stamp is what gives their request rows a TenantId.
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
||||
|
||||
// 2. Check rate limit (DoS protection)
|
||||
// Effective limit = per-tenant override if set, otherwise the global default.
|
||||
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||||
@@ -510,6 +518,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
"Device validation transient failure for tenant {TenantId}, serial {SerialNumber}. Returning 503 Retry-After.",
|
||||
tenantId, serialNumber);
|
||||
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Transient);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
@@ -523,6 +532,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
LogRequestRejection("device", tenantId, req, sessionId,
|
||||
extraReason: deviceValidationError,
|
||||
thumbprint: certValidation.Thumbprint);
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Rejected);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
@@ -595,7 +605,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
});
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
// All checks passed. The admitting validator rides on the request row: the Sessions
|
||||
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
||||
RequestRowMarkers.DeviceValidationValue(validatedBy));
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
|
||||
@@ -322,6 +322,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
rule.Notify = null;
|
||||
}
|
||||
|
||||
var existing = (await _ruleRepo.GetAnalyzeRulesAsync(tenantId))
|
||||
.FirstOrDefault(r => r.RuleId == rule.RuleId && !r.IsBuiltIn && !r.IsCommunity);
|
||||
if (existing != null)
|
||||
{
|
||||
rule.CreatedAt = existing.CreatedAt;
|
||||
// Author is stamped from the creator's token at create time and is
|
||||
// immutable afterwards — updates (incl. cross-tenant GA edits) keep it.
|
||||
rule.Author = existing.Author;
|
||||
}
|
||||
|
||||
rule.UpdatedAt = DateTime.UtcNow;
|
||||
return await _ruleRepo.StoreAnalyzeRuleAsync(rule, tenantId);
|
||||
}
|
||||
@@ -333,7 +343,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public async Task<AnalyzeRule> CreateFromTemplateAsync(
|
||||
string tenantId,
|
||||
string templateRuleId,
|
||||
Dictionary<string, string> variableValues)
|
||||
Dictionary<string, string> variableValues,
|
||||
string? author = null)
|
||||
{
|
||||
var allRules = await GetAllRulesForTenantAsync(tenantId);
|
||||
var template = allRules.FirstOrDefault(r => r.RuleId == templateRuleId);
|
||||
@@ -370,6 +381,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
customRule.RuleId = $"{templateRuleId}-CUSTOM";
|
||||
customRule.IsBuiltIn = false;
|
||||
customRule.IsCommunity = false;
|
||||
// The copy is the tenant's rule, not the template author's.
|
||||
customRule.Author = string.IsNullOrWhiteSpace(author) ? "Autopilot Monitor" : author!;
|
||||
customRule.Enabled = true;
|
||||
customRule.DerivedFromTemplateRuleId = templateRuleId;
|
||||
customRule.TemplateVariables = new List<TemplateVariable>();
|
||||
|
||||
+11
@@ -92,6 +92,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
classification.HasCompletionEvidenceCandidate = true;
|
||||
break;
|
||||
case "agent_shutting_down":
|
||||
// Every shutdown ends this agent run's observation; the max-lifetime
|
||||
// flavour additionally carries the verdict mapping below.
|
||||
classification.AgentShutdownEvent = evt;
|
||||
if (IsMaxLifetimeAgentShutdown(evt))
|
||||
classification.AgentMaxLifetimeShutdownEvent = evt;
|
||||
break;
|
||||
@@ -705,6 +708,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public EnrollmentEvent? EspFailureEvent { get; set; }
|
||||
public EnrollmentEvent? SessionStalledEvent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any <c>agent_shutting_down</c> in the batch — the agent's last word for this run.
|
||||
/// After the status write, a terminal session closes its still-open app rows on this
|
||||
/// batch: apps that started after the verdict (the agent keeps watching after
|
||||
/// enrollment_complete) got their row only after the terminal seam had already run.
|
||||
/// </summary>
|
||||
public EnrollmentEvent? AgentShutdownEvent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>agent_shutting_down</c> with <c>Data.reason == "max_lifetime"</c> — the V2
|
||||
/// watchdog shutdown that deliberately carries no enrollment verdict. Mapped to a
|
||||
|
||||
@@ -276,6 +276,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
updatedSession?.AgentVersion, skewScan);
|
||||
}
|
||||
|
||||
// Observation end: the batch carrying agent_shutting_down is the agent's last word.
|
||||
// On a terminal session an app row still InProgress now will never get a terminal
|
||||
// event — close it as Incomplete. The terminal seam already did this on the verdict
|
||||
// batch; this re-run catches rows opened after the verdict (the IME keeps installing
|
||||
// and the agent keeps watching after enrollment_complete). Non-terminal sessions
|
||||
// (reboot shutdown, WhiteGlove part 1) keep their rows open. Idempotent + fail-soft.
|
||||
if (classification.AgentShutdownEvent != null
|
||||
&& updatedSession != null
|
||||
&& (updatedSession.Status == SessionStatus.Succeeded
|
||||
|| updatedSession.Status == SessionStatus.Failed
|
||||
|| updatedSession.Status == SessionStatus.Incomplete))
|
||||
{
|
||||
await _metricsRepo.CloseOpenAppInstallsForSessionAsync(request.TenantId, request.SessionId);
|
||||
}
|
||||
|
||||
// Auto-analyze fan-out: enqueue a queue message instead of running fire-and-forget
|
||||
// Task.Run inside the function. The previous in-function approach could be killed
|
||||
// mid-flight by Functions scale-in (HTTP 200 returned → worker unloaded → rules
|
||||
|
||||
@@ -328,7 +328,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Idempotent, positive-evidence-only; also heals sessions that went
|
||||
// terminal before the resolution seam existed (hadNoRow backfill).
|
||||
if (eventCountMoved || hadNoRow)
|
||||
{
|
||||
await _metricsRepo.ResolveEspBlockingForSessionAsync(session.TenantId, session.SessionId);
|
||||
// Same late-batch reasoning: an app row opened after the agent's
|
||||
// shutdown batch was never closed — the sweep is its self-heal.
|
||||
await _metricsRepo.CloseOpenAppInstallsForSessionAsync(session.TenantId, session.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (breakdown == null)
|
||||
@@ -1045,6 +1050,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogWarning(ex, "Failed to cleanup old tenant notifications");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Closed community rule submissions: withdrawn 30d, declined 90d (RuleSubmissionRetention);
|
||||
// pending/approved/published rows are never swept.
|
||||
var deleted = await _ruleSubmissionRepo.DeleteExpiredAsync(now);
|
||||
if (deleted > 0)
|
||||
_logger.LogInformation("Rule submissions cleanup: deleted {Count} closed rows (withdrawn {WithdrawnDays}d / declined {DeclinedDays}d)",
|
||||
deleted, RuleSubmissionRetention.WithdrawnDays, RuleSubmissionRetention.DeclinedDays);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup closed rule submissions");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deleted = await _hardwareRejectionTracker.DeleteOlderThanAsync(now.AddDays(-hardwareRejectionRetentionDays));
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly INotificationRepository _notificationRepo;
|
||||
private readonly ITenantNotificationRepository _tenantNotificationRepo;
|
||||
private readonly IHardwareRejectionNotificationTracker _hardwareRejectionTracker;
|
||||
private readonly IRuleSubmissionRepository _ruleSubmissionRepo;
|
||||
private readonly IDelegationInvitationRepository _delegationInvitationRepo;
|
||||
private readonly DataAccess.TableStorage.BackupJobsRepository _backupJobsRepo;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
@@ -75,6 +76,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
INotificationRepository notificationRepo,
|
||||
ITenantNotificationRepository tenantNotificationRepo,
|
||||
IHardwareRejectionNotificationTracker hardwareRejectionTracker,
|
||||
IRuleSubmissionRepository ruleSubmissionRepo,
|
||||
IDelegationInvitationRepository delegationInvitationRepo,
|
||||
DataAccess.TableStorage.BackupJobsRepository backupJobsRepo,
|
||||
OpsEventService opsEventService,
|
||||
@@ -103,6 +105,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_opsEventRepo = opsEventRepo;
|
||||
_notificationRepo = notificationRepo;
|
||||
_tenantNotificationRepo = tenantNotificationRepo;
|
||||
_ruleSubmissionRepo = ruleSubmissionRepo;
|
||||
_hardwareRejectionTracker = hardwareRejectionTracker;
|
||||
_delegationInvitationRepo = delegationInvitationRepo;
|
||||
_backupJobsRepo = backupJobsRepo;
|
||||
|
||||
@@ -204,6 +204,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return Compose(userLimits, await ResolveTenantLimitsAsync(chargeTenantId, definitions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One charged tenant's organization windows alone (plan name + tenant limits; no counters, no per-user
|
||||
/// part) — for the organization usage report.
|
||||
/// </summary>
|
||||
public virtual async Task<TenantPlanLimits> ResolveTenantPlanAsync(string chargeTenantId)
|
||||
=> await ResolveTenantLimitsAsync(chargeTenantId, await LoadDefinitionsAsync());
|
||||
|
||||
private async Task<(UserPlanLimits Limits, List<PlanTierDefinition> Definitions)> ResolveUserLimitsAsync(
|
||||
AdminIdentity? identity, string? homeTenantId)
|
||||
{
|
||||
@@ -227,7 +234,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var planName = overridePlan ?? await _entitlementService.GetMcpUsagePlanNameAsync(homeTenantId);
|
||||
|
||||
// 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks.
|
||||
var definitions = await LoadDefinitionsAsync(planName);
|
||||
var definitions = await LoadDefinitionsAsync();
|
||||
|
||||
// User limits: the definition for the (possibly overridden) plan name, else the catalog fallback
|
||||
// for the edition plans, else Community (fail-closed for overrides naming a plan that exists nowhere).
|
||||
@@ -257,7 +264,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
private async Task<List<PlanTierDefinition>> LoadDefinitionsAsync(string planName)
|
||||
private async Task<List<PlanTierDefinition>> LoadDefinitionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -266,7 +273,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName);
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback");
|
||||
return new List<PlanTierDefinition>();
|
||||
}
|
||||
}
|
||||
@@ -393,7 +400,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
private sealed record UserPlanLimits(string PlanName, int DailyLimit, int MonthlyLimit);
|
||||
private sealed record TenantPlanLimits(string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
|
||||
|
||||
/// <summary>The charged tenant's plan name and organization-wide windows (0 = unlimited).</summary>
|
||||
public sealed record TenantPlanLimits(string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
{
|
||||
(Constants.TableNames.BootstrapSessions, "CodeLookup"),
|
||||
(Constants.TableNames.SessionReports, "reports"),
|
||||
(Constants.TableNames.RuleSubmissions, "submissions"),
|
||||
};
|
||||
|
||||
// Plan §6.3.2 — Variant C (TenantId property only).
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Community rule submissions: freezes a tenant's custom rule at submit time, records the
|
||||
/// pre-flight findings and fire telemetry, and carries the review through to the repo file.
|
||||
/// Publishing itself is a repository commit plus reseed — this service never writes the
|
||||
/// global rule partition; <c>published</c> is derived from the catalog on every read.
|
||||
/// </summary>
|
||||
public class RuleSubmissionService
|
||||
{
|
||||
public const int MaxRuleJsonLength = 32_000;
|
||||
public const int MaxSubmissionsPerTenantPerDay = 10;
|
||||
public const int FireStatsDays = 30;
|
||||
|
||||
/// <summary>The credit text is published in the repo, so it is a name and nothing else.</summary>
|
||||
private static readonly Regex AttributionNameShape = new(@"^[\p{L}\p{N} .,'&()\-]{1,64}$", RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>Rule fields that belong to the server or the tenant, never to a frozen or published rule.</summary>
|
||||
private static readonly string[] StrippedFields =
|
||||
{
|
||||
"isBuiltIn", "provenance", "createdAt", "updatedAt",
|
||||
"markSessionAsFailed", "notifyDefault", "notify", "notifyChannelIds",
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions RepoFileOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
// The dominant convention per kind in rules/ (ANALYZE-APP vs GATHER-APPS …); an unmapped
|
||||
// category falls back to its upper-cased name, which the reviewer can overrule.
|
||||
private static readonly Dictionary<string, string> AnalyzeCategoryCodes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["apps"] = "APP", ["device"] = "DEV", ["enrollment"] = "ENRL", ["esp"] = "ESP",
|
||||
["identity"] = "ID", ["network"] = "NET", ["security"] = "SEC",
|
||||
};
|
||||
private static readonly Dictionary<string, string> GatherCategoryCodes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["apps"] = "APPS", ["device"] = "DEVICE", ["enrollment"] = "ENROLL", ["esp"] = "ESP",
|
||||
["identity"] = "ID", ["network"] = "NET",
|
||||
};
|
||||
|
||||
private readonly IRuleSubmissionRepository _submissions;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly ILogger<RuleSubmissionService> _logger;
|
||||
|
||||
public RuleSubmissionService(
|
||||
IRuleSubmissionRepository submissions,
|
||||
IRuleRepository ruleRepo,
|
||||
IMetricsRepository metricsRepo,
|
||||
TenantConfigurationService configService,
|
||||
ILogger<RuleSubmissionService> logger)
|
||||
{
|
||||
_submissions = submissions;
|
||||
_ruleRepo = ruleRepo;
|
||||
_metricsRepo = metricsRepo;
|
||||
_configService = configService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Freezes and stores every referenced rule. All-or-nothing: a pre-flight error on any
|
||||
/// item rejects the whole batch (<see cref="ArgumentException"/>), so a tenant never ends
|
||||
/// up with half a submission. <see cref="InvalidOperationException"/> = conflict (open
|
||||
/// duplicate, daily cap).
|
||||
/// </summary>
|
||||
public async Task<List<RuleSubmission>> SubmitAsync(
|
||||
string tenantId, SubmitRuleSubmissionsRequest request, string submittedBy, string submittedByName)
|
||||
{
|
||||
if (request.Items == null || request.Items.Count == 0)
|
||||
throw new ArgumentException("At least one rule is required.");
|
||||
if (request.Items.Count > SubmitRuleSubmissionsRequest.MaxItems)
|
||||
throw new ArgumentException($"At most {SubmitRuleSubmissionsRequest.MaxItems} rules per submission.");
|
||||
if (request.Comment != null && request.Comment.Length > SubmitRuleSubmissionsRequest.MaxCommentLength)
|
||||
throw new ArgumentException($"Comment exceeds {SubmitRuleSubmissionsRequest.MaxCommentLength} characters.");
|
||||
|
||||
var attributionName = await ResolveAttributionNameAsync(tenantId, request.AttributionMode, request.AttributionName);
|
||||
var contactEmail = NormalizeEmail(request.Email);
|
||||
|
||||
var distinct = request.Items
|
||||
.Select(i => (Kind: i.Kind?.Trim().ToLowerInvariant() ?? string.Empty, RuleId: i.RuleId?.Trim() ?? string.Empty))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
foreach (var (kind, ruleId) in distinct)
|
||||
{
|
||||
if (!RuleSubmissionKinds.IsKnown(kind))
|
||||
throw new ArgumentException($"Unknown rule kind '{kind}'.");
|
||||
if (string.IsNullOrEmpty(ruleId))
|
||||
throw new ArgumentException("ruleId is required for every item.");
|
||||
}
|
||||
|
||||
var existing = await _submissions.GetForTenantAsync(tenantId);
|
||||
var since = DateTime.UtcNow.AddDays(-1);
|
||||
if (existing.Count(s => s.SubmittedAt >= since) + distinct.Count > MaxSubmissionsPerTenantPerDay)
|
||||
throw new InvalidOperationException($"At most {MaxSubmissionsPerTenantPerDay} rule submissions per tenant and day.");
|
||||
|
||||
var analyzeRules = distinct.Any(d => d.Kind == RuleSubmissionKinds.Analyze)
|
||||
? await _ruleRepo.GetAnalyzeRulesAsync(tenantId) : new List<AnalyzeRule>();
|
||||
var gatherRules = distinct.Any(d => d.Kind == RuleSubmissionKinds.Gather)
|
||||
? await _ruleRepo.GetGatherRulesAsync(tenantId) : new List<GatherRule>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var batchId = NewId();
|
||||
var prepared = new List<RuleSubmission>(distinct.Count);
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var (kind, ruleId) in distinct)
|
||||
{
|
||||
if (existing.Any(s => s.Status == RuleSubmissionStatuses.Pending
|
||||
&& s.RuleKind == kind
|
||||
&& string.Equals(s.SourceRuleId, ruleId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Rule '{ruleId}' already has a pending submission.");
|
||||
|
||||
var submission = new RuleSubmission
|
||||
{
|
||||
SubmissionId = NewId(),
|
||||
BatchId = batchId,
|
||||
TenantId = tenantId,
|
||||
RuleKind = kind,
|
||||
SourceRuleId = ruleId,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment!.Trim(),
|
||||
SubmittedBy = submittedBy,
|
||||
SubmittedByName = submittedByName,
|
||||
ContactEmail = contactEmail,
|
||||
AttributionMode = request.AttributionMode,
|
||||
AttributionName = attributionName,
|
||||
SubmittedAt = now,
|
||||
Status = RuleSubmissionStatuses.Pending,
|
||||
};
|
||||
|
||||
var findings = new List<RuleSubmissionFinding>();
|
||||
if (kind == RuleSubmissionKinds.Analyze)
|
||||
{
|
||||
var rule = analyzeRules.FirstOrDefault(r => string.Equals(r.RuleId, ruleId, StringComparison.OrdinalIgnoreCase)
|
||||
&& !r.IsBuiltIn && !r.IsCommunity);
|
||||
if (rule == null) { errors.Add($"{ruleId}: not a custom analyze rule of this tenant."); continue; }
|
||||
|
||||
submission.Title = rule.Title ?? string.Empty;
|
||||
submission.Category = rule.Category ?? string.Empty;
|
||||
submission.DerivedFromTemplateRuleId = rule.DerivedFromTemplateRuleId;
|
||||
submission.RuleJson = FreezeRule(rule);
|
||||
foreach (var e in DryRunAnalyzeRuleFunction.ValidateDraftRule(rule))
|
||||
findings.Add(new RuleSubmissionFinding { Level = "error", Message = e });
|
||||
if (!string.IsNullOrEmpty(rule.DerivedFromTemplateRuleId))
|
||||
findings.Add(new RuleSubmissionFinding { Level = "info", Message = $"Derived from template {rule.DerivedFromTemplateRuleId} — usually a filled-in copy rather than a new detection." });
|
||||
}
|
||||
else
|
||||
{
|
||||
var rule = gatherRules.FirstOrDefault(r => string.Equals(r.RuleId, ruleId, StringComparison.OrdinalIgnoreCase)
|
||||
&& !r.IsBuiltIn && !r.IsCommunity);
|
||||
if (rule == null) { errors.Add($"{ruleId}: not a custom gather rule of this tenant."); continue; }
|
||||
|
||||
submission.Title = rule.Title ?? string.Empty;
|
||||
submission.Category = rule.Category ?? string.Empty;
|
||||
submission.RuleJson = FreezeRule(rule);
|
||||
if (string.IsNullOrWhiteSpace(rule.CollectorType)) findings.Add(new RuleSubmissionFinding { Level = "error", Message = "collectorType is required" });
|
||||
if (string.IsNullOrWhiteSpace(rule.Target)) findings.Add(new RuleSubmissionFinding { Level = "error", Message = "target is required" });
|
||||
if (string.IsNullOrWhiteSpace(rule.Trigger)) findings.Add(new RuleSubmissionFinding { Level = "error", Message = "trigger is required" });
|
||||
if (string.IsNullOrWhiteSpace(rule.OutputEventType)) findings.Add(new RuleSubmissionFinding { Level = "error", Message = "outputEventType is required" });
|
||||
var scopeError = GatherRulesFunction.ValidateScopeAndEmitMode(rule);
|
||||
if (scopeError != null) findings.Add(new RuleSubmissionFinding { Level = "error", Message = scopeError });
|
||||
findings.Add(new RuleSubmissionFinding { Level = "info", Message = "Collector guardrails are enforced on the device; the full allowlist check runs in the MCP validate_rule tool." });
|
||||
}
|
||||
|
||||
if (submission.RuleJson.Length > MaxRuleJsonLength)
|
||||
findings.Add(new RuleSubmissionFinding { Level = "error", Message = $"rule document exceeds {MaxRuleJsonLength} characters" });
|
||||
|
||||
var itemErrors = findings.Where(f => f.Level == "error").Select(f => $"{ruleId}: {f.Message}").ToList();
|
||||
if (itemErrors.Count > 0) { errors.AddRange(itemErrors); continue; }
|
||||
|
||||
submission.ValidationFindings = findings;
|
||||
submission.SourceFireStats = await GetFireStatsAsync(tenantId, kind, ruleId);
|
||||
prepared.Add(submission);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
throw new ArgumentException("Submission rejected: " + string.Join(" | ", errors));
|
||||
|
||||
foreach (var submission in prepared)
|
||||
{
|
||||
if (!await _submissions.AddAsync(submission))
|
||||
throw new InvalidOperationException("Failed to store the submission.");
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private async Task<string> ResolveAttributionNameAsync(string tenantId, string? mode, string? requestedName)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case RuleAttributionModes.Anonymous:
|
||||
return RuleAttributionModes.AnonymousAuthor;
|
||||
case RuleAttributionModes.Organization:
|
||||
{
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(tenantId);
|
||||
var name = config?.CompanyName;
|
||||
if (string.IsNullOrWhiteSpace(name)) name = config?.DomainName;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("No organization name is known for this tenant — set the company name in the tenant settings or choose another attribution.");
|
||||
return ValidateAttributionName(name!.Trim());
|
||||
}
|
||||
case RuleAttributionModes.Person:
|
||||
if (string.IsNullOrWhiteSpace(requestedName))
|
||||
throw new ArgumentException("attributionName is required for the person attribution.");
|
||||
return ValidateAttributionName(requestedName!.Trim());
|
||||
default:
|
||||
throw new ArgumentException($"attributionMode must be one of: {string.Join(", ", RuleAttributionModes.All)}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trimmed, or null when empty; the shape check is deliberately loose (exactly one @ with something on both sides, no whitespace, at most 254 characters).</summary>
|
||||
internal static string? NormalizeEmail(string? email)
|
||||
{
|
||||
var trimmed = email?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) return null;
|
||||
var at = trimmed!.IndexOf('@');
|
||||
if (trimmed.Length > SubmitRuleSubmissionsRequest.MaxEmailLength
|
||||
|| at <= 0
|
||||
|| at != trimmed.LastIndexOf('@')
|
||||
|| at == trimmed.Length - 1
|
||||
|| trimmed.Any(char.IsWhiteSpace))
|
||||
throw new ArgumentException("email is not a valid address.");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
internal static string ValidateAttributionName(string name)
|
||||
{
|
||||
if (!AttributionNameShape.IsMatch(name))
|
||||
throw new ArgumentException("attributionName may only contain letters, digits, spaces and . , ' & ( ) - (1–64 characters).");
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>Wire JSON of the rule minus server and tenant fields — what the reviewer sees and what ships.</summary>
|
||||
internal static string FreezeRule<T>(T rule)
|
||||
{
|
||||
var node = JsonSerializer.SerializeToNode(rule, ApiJsonOptions.Create()) as JsonObject
|
||||
?? throw new InvalidOperationException("Rule did not serialize to an object.");
|
||||
foreach (var field in StrippedFields) node.Remove(field);
|
||||
return node.ToJsonString();
|
||||
}
|
||||
|
||||
private async Task<RuleSubmissionFireStats?> GetFireStatsAsync(string tenantId, string kind, string ruleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var end = DateTime.UtcNow;
|
||||
var start = end.AddDays(-FireStatsDays);
|
||||
var entries = await _metricsRepo.GetRuleStatsAsync(
|
||||
tenantId, start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
end.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), kind);
|
||||
var mine = entries.Where(e => string.Equals(e.RuleId, ruleId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
return new RuleSubmissionFireStats
|
||||
{
|
||||
Days = FireStatsDays,
|
||||
FireCount = mine.Sum(e => e.FireCount),
|
||||
SessionsEvaluated = mine.Sum(e => e.SessionsEvaluated),
|
||||
EvaluationCount = mine.Sum(e => e.EvaluationCount),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Fire stats unavailable for {RuleId} (tenant {TenantId})", ruleId, tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read ────────────────────────────────────────────────────────────────
|
||||
|
||||
public Task<RuleSubmission?> GetAsync(string submissionId) => _submissions.GetAsync(submissionId);
|
||||
|
||||
public Task<List<RuleSubmission>> GetForTenantAsync(string tenantId) => _submissions.GetForTenantAsync(tenantId);
|
||||
|
||||
public Task<RawPage<RuleSubmission>> GetPageAsync(string? tenantId, string? status, int pageSize, string? continuation)
|
||||
=> _submissions.GetPageAsync(tenantId, status, pageSize, continuation);
|
||||
|
||||
/// <summary>Projects rows to the wire shape with the effective status (<c>published</c> derived from the catalog).</summary>
|
||||
public async Task<List<RuleSubmissionItem>> ToItemsAsync(IEnumerable<RuleSubmission> rows)
|
||||
{
|
||||
var list = rows.ToList();
|
||||
HashSet<string>? globalAnalyze = null, globalGather = null;
|
||||
if (list.Any(s => s.Status == RuleSubmissionStatuses.Approved && !string.IsNullOrEmpty(s.PublishedRuleId)))
|
||||
{
|
||||
globalAnalyze = (await _ruleRepo.GetAnalyzeRulesAsync("global")).Select(r => r.RuleId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
globalGather = (await _ruleRepo.GetGatherRulesAsync("global")).Select(r => r.RuleId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return list.Select(s => ToItem(s, IsPublished(s, globalAnalyze, globalGather))).ToList();
|
||||
}
|
||||
|
||||
private static bool IsPublished(RuleSubmission s, HashSet<string>? globalAnalyze, HashSet<string>? globalGather)
|
||||
{
|
||||
if (s.Status != RuleSubmissionStatuses.Approved || string.IsNullOrEmpty(s.PublishedRuleId)) return false;
|
||||
var set = s.RuleKind == RuleSubmissionKinds.Analyze ? globalAnalyze : globalGather;
|
||||
return set != null && set.Contains(s.PublishedRuleId!);
|
||||
}
|
||||
|
||||
internal static RuleSubmissionItem ToItem(RuleSubmission s, bool published) => new()
|
||||
{
|
||||
SubmissionId = s.SubmissionId,
|
||||
BatchId = s.BatchId,
|
||||
TenantId = s.TenantId,
|
||||
RuleKind = s.RuleKind,
|
||||
SourceRuleId = s.SourceRuleId,
|
||||
Title = s.Title,
|
||||
Category = s.Category,
|
||||
Comment = s.Comment,
|
||||
SubmittedBy = s.SubmittedBy,
|
||||
SubmittedByName = s.SubmittedByName,
|
||||
ContactEmail = s.ContactEmail,
|
||||
AttributionMode = s.AttributionMode,
|
||||
AttributionName = s.AttributionName,
|
||||
SubmittedAt = s.SubmittedAt,
|
||||
Status = published ? RuleSubmissionStatuses.Published : s.Status,
|
||||
ValidationFindings = s.ValidationFindings,
|
||||
SourceFireStats = s.SourceFireStats,
|
||||
ReviewedBy = s.ReviewedBy,
|
||||
ReviewedAt = s.ReviewedAt,
|
||||
ReviewComment = s.ReviewComment,
|
||||
WillBeAdapted = s.WillBeAdapted,
|
||||
PublishedRuleId = s.PublishedRuleId,
|
||||
DerivedFromTemplateRuleId = s.DerivedFromTemplateRuleId,
|
||||
};
|
||||
|
||||
/// <summary>Everything the reviewer (human or AI) needs for one submission.</summary>
|
||||
public async Task<RuleSubmissionDetailResponse?> GetDetailAsync(string submissionId)
|
||||
{
|
||||
var s = await _submissions.GetAsync(submissionId);
|
||||
if (s == null) return null;
|
||||
|
||||
var item = (await ToItemsAsync(new[] { s }))[0];
|
||||
var response = new RuleSubmissionDetailResponse { Success = true, Submission = item };
|
||||
|
||||
if (s.RuleKind == RuleSubmissionKinds.Analyze)
|
||||
{
|
||||
var rule = JsonSerializer.Deserialize<AnalyzeRule>(s.RuleJson, ApiJsonOptions.Read);
|
||||
if (rule != null) { rule.IsBuiltIn = false; rule.IsCommunity = false; }
|
||||
response.AnalyzeRule = rule;
|
||||
}
|
||||
else
|
||||
{
|
||||
var rule = JsonSerializer.Deserialize<GatherRule>(s.RuleJson, ApiJsonOptions.Read);
|
||||
if (rule != null) { rule.IsBuiltIn = false; rule.IsCommunity = false; }
|
||||
response.GatherRule = rule;
|
||||
}
|
||||
|
||||
response.LiveFireStats = await GetFireStatsAsync(s.TenantId, s.RuleKind, s.SourceRuleId);
|
||||
response.SuggestedPublishedRuleId = s.PublishedRuleId ?? await SuggestPublishedRuleIdAsync(s.RuleKind, s.Category);
|
||||
var fileId = s.PublishedRuleId ?? response.SuggestedPublishedRuleId;
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
response.RepoFile = BuildRepoFile(s, fileId!);
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── Review ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Applies a reviewer decision. Transitions: pending → approved | declined; approved → declined
|
||||
/// while not yet published. <see cref="KeyNotFoundException"/> = unknown id,
|
||||
/// <see cref="ArgumentException"/> = bad input, <see cref="InvalidOperationException"/> = conflict.
|
||||
/// </summary>
|
||||
public async Task<RuleSubmission> ReviewAsync(string submissionId, ReviewRuleSubmissionRequest request, string reviewedBy)
|
||||
{
|
||||
var s = await _submissions.GetAsync(submissionId) ?? throw new KeyNotFoundException("Submission not found.");
|
||||
var comment = string.IsNullOrWhiteSpace(request.ReviewComment) ? null : request.ReviewComment!.Trim();
|
||||
if (comment != null && comment.Length > ReviewRuleSubmissionRequest.MaxReviewCommentLength)
|
||||
throw new ArgumentException($"reviewComment exceeds {ReviewRuleSubmissionRequest.MaxReviewCommentLength} characters.");
|
||||
|
||||
var published = (await ToItemsAsync(new[] { s }))[0].Status == RuleSubmissionStatuses.Published;
|
||||
if (published)
|
||||
throw new InvalidOperationException("The rule is already published; the submission is final.");
|
||||
if (s.Status == RuleSubmissionStatuses.Withdrawn)
|
||||
throw new InvalidOperationException("The submitter withdrew this submission.");
|
||||
|
||||
switch (request.Decision)
|
||||
{
|
||||
case RuleSubmissionDecisions.Approve:
|
||||
{
|
||||
if (s.Status != RuleSubmissionStatuses.Pending)
|
||||
throw new InvalidOperationException($"Only a pending submission can be approved (status: {s.Status}).");
|
||||
var publishedRuleId = request.PublishedRuleId?.Trim();
|
||||
if (string.IsNullOrEmpty(publishedRuleId))
|
||||
throw new ArgumentException("publishedRuleId is required to approve.");
|
||||
await ValidatePublishedRuleIdAsync(s, publishedRuleId!);
|
||||
s.Status = RuleSubmissionStatuses.Approved;
|
||||
s.PublishedRuleId = publishedRuleId;
|
||||
s.WillBeAdapted = request.WillBeAdapted ?? false;
|
||||
break;
|
||||
}
|
||||
case RuleSubmissionDecisions.Decline:
|
||||
{
|
||||
if (s.Status != RuleSubmissionStatuses.Pending && s.Status != RuleSubmissionStatuses.Approved)
|
||||
throw new InvalidOperationException($"Cannot decline a submission in status {s.Status}.");
|
||||
if (comment == null)
|
||||
throw new ArgumentException("reviewComment is required to decline — the submitter sees it.");
|
||||
s.Status = RuleSubmissionStatuses.Declined;
|
||||
s.PublishedRuleId = null;
|
||||
s.WillBeAdapted = false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ArgumentException($"decision must be one of: {string.Join(", ", RuleSubmissionDecisions.All)}.");
|
||||
}
|
||||
|
||||
s.ReviewComment = comment;
|
||||
s.ReviewedBy = reviewedBy;
|
||||
s.ReviewedAt = DateTime.UtcNow;
|
||||
|
||||
if (!await _submissions.UpdateAsync(s))
|
||||
throw new InvalidOperationException("The submission changed underneath this review; reload and retry.");
|
||||
return s;
|
||||
}
|
||||
|
||||
private async Task ValidatePublishedRuleIdAsync(RuleSubmission s, string publishedRuleId)
|
||||
{
|
||||
var kindPrefix = s.RuleKind == RuleSubmissionKinds.Analyze ? "ANALYZE-" : "GATHER-";
|
||||
if (!RuleIdPolicy.IsReservedBuiltInId(publishedRuleId)
|
||||
|| !publishedRuleId.StartsWith(kindPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| !Regex.IsMatch(publishedRuleId, @"^[A-Z]+-[A-Z]+-\d{3}$"))
|
||||
throw new ArgumentException($"publishedRuleId must be {kindPrefix}<CATEGORY>-<NNN> in the reserved built-in namespace (upper case, three digits).");
|
||||
|
||||
var taken = await GetTakenIdsAsync(s.RuleKind);
|
||||
if (taken.Contains(publishedRuleId))
|
||||
throw new InvalidOperationException($"Rule id '{publishedRuleId}' is already taken (catalog, global partition or another approved submission).");
|
||||
}
|
||||
|
||||
/// <summary>Tenant-side withdrawal; only while pending. False when there is nothing to withdraw.</summary>
|
||||
public async Task<bool> WithdrawAsync(string tenantId, string submissionId, bool crossTenantAllowed)
|
||||
{
|
||||
var s = await _submissions.GetAsync(submissionId);
|
||||
if (s == null) return false;
|
||||
if (!crossTenantAllowed && !string.Equals(s.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (s.Status != RuleSubmissionStatuses.Pending)
|
||||
throw new InvalidOperationException($"Only a pending submission can be withdrawn (status: {s.Status}).");
|
||||
s.Status = RuleSubmissionStatuses.Withdrawn;
|
||||
return await _submissions.UpdateAsync(s);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator hard delete — the row disappears from the operator list and the tenant's list
|
||||
/// alike. Not part of normal operations: for test and demo submissions, or on a tenant's request.
|
||||
/// </summary>
|
||||
public Task<bool> DeleteAsync(string submissionId) => _submissions.DeleteAsync(submissionId);
|
||||
|
||||
/// <summary>Daily retention sweep of closed submissions (see <see cref="RuleSubmissionRetention"/>).</summary>
|
||||
public Task<int> SweepExpiredAsync(DateTime nowUtc) => _submissions.DeleteExpiredAsync(nowUtc);
|
||||
|
||||
// ── Id suggestion + repo file ───────────────────────────────────────────
|
||||
|
||||
private async Task<HashSet<string>> GetTakenIdsAsync(string kind)
|
||||
{
|
||||
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (kind == RuleSubmissionKinds.Analyze)
|
||||
{
|
||||
foreach (var r in BuiltInAnalyzeRules.GetAll()) taken.Add(r.RuleId);
|
||||
foreach (var r in await _ruleRepo.GetAnalyzeRulesAsync("global")) taken.Add(r.RuleId);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var r in BuiltInGatherRules.GetAll()) taken.Add(r.RuleId);
|
||||
foreach (var r in await _ruleRepo.GetGatherRulesAsync("global")) taken.Add(r.RuleId);
|
||||
}
|
||||
foreach (var id in await _submissions.GetReservedPublishedRuleIdsAsync()) taken.Add(id);
|
||||
return taken;
|
||||
}
|
||||
|
||||
public async Task<string> SuggestPublishedRuleIdAsync(string kind, string category)
|
||||
=> SuggestPublishedRuleId(kind, category, await GetTakenIdsAsync(kind));
|
||||
|
||||
/// <summary>
|
||||
/// Max + 1 within <c>{KIND}-{CODE}-</c> over every taken id. Gaps are retired ids that may
|
||||
/// return (RuleIdPolicy) and are never reused.
|
||||
/// </summary>
|
||||
internal static string SuggestPublishedRuleId(string kind, string category, IEnumerable<string> takenIds)
|
||||
{
|
||||
var codes = kind == RuleSubmissionKinds.Analyze ? AnalyzeCategoryCodes : GatherCategoryCodes;
|
||||
var code = codes.TryGetValue(category ?? string.Empty, out var mapped)
|
||||
? mapped
|
||||
: Regex.Replace((category ?? "MISC").ToUpperInvariant(), "[^A-Z]", "");
|
||||
if (code.Length == 0) code = "MISC";
|
||||
var prefix = (kind == RuleSubmissionKinds.Analyze ? "ANALYZE-" : "GATHER-") + code + "-";
|
||||
|
||||
var max = 0;
|
||||
foreach (var id in takenIds)
|
||||
{
|
||||
if (!id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (int.TryParse(id.Substring(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n) && n > max)
|
||||
max = n;
|
||||
}
|
||||
return prefix + (max + 1).ToString("D3", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The file that ships the rule: schema pointer first, the assigned id, the chosen credit,
|
||||
/// <c>isCommunity</c>, and <c>enabled</c> after the built-in convention (analyze rules ship
|
||||
/// on, gather rules opt-in because they cost device work). Lineage to the tenant's template
|
||||
/// copy is dropped.
|
||||
/// </summary>
|
||||
internal static RuleSubmissionRepoFile BuildRepoFile(RuleSubmission s, string publishedRuleId)
|
||||
{
|
||||
var frozen = JsonNode.Parse(s.RuleJson) as JsonObject
|
||||
?? throw new InvalidOperationException("Frozen rule is not an object.");
|
||||
var isAnalyze = s.RuleKind == RuleSubmissionKinds.Analyze;
|
||||
|
||||
var ordered = new JsonObject
|
||||
{
|
||||
["$schema"] = isAnalyze ? "../schema/analyze-rule.schema.json" : "../schema/gather-rule.schema.json",
|
||||
["ruleId"] = publishedRuleId,
|
||||
};
|
||||
foreach (var head in isAnalyze
|
||||
? new[] { "title", "description", "severity", "category", "version" }
|
||||
: new[] { "title", "description", "category", "version" })
|
||||
{
|
||||
if (frozen.TryGetPropertyValue(head, out var value)) ordered[head] = value?.DeepClone();
|
||||
}
|
||||
ordered["author"] = s.AttributionName;
|
||||
ordered["enabled"] = isAnalyze;
|
||||
ordered["isCommunity"] = true;
|
||||
|
||||
foreach (var kv in frozen)
|
||||
{
|
||||
if (ordered.ContainsKey(kv.Key)) continue;
|
||||
if (kv.Key == "derivedFromTemplateRuleId") continue;
|
||||
if (Array.IndexOf(StrippedFields, kv.Key) >= 0) continue;
|
||||
ordered[kv.Key] = kv.Value?.DeepClone();
|
||||
}
|
||||
|
||||
var folder = isAnalyze ? "analyze" : "gather";
|
||||
return new RuleSubmissionRepoFile
|
||||
{
|
||||
Path = $"rules/{folder}/{publishedRuleId}.json",
|
||||
Content = ordered.ToJsonString(RepoFileOptions) + "\n",
|
||||
};
|
||||
}
|
||||
|
||||
private static string NewId() => Guid.NewGuid().ToString("N").Substring(0, 12);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +253,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Status "InProgress" — a real (non-sentinel) value that Merge-mode would
|
||||
// happily write over a row the terminal batch already closed. Only another
|
||||
// terminal observation may change a terminal status (Failed→Succeeded on a
|
||||
// successful retry stays legal).
|
||||
// successful retry stays legal). Incomplete (observation-end close step) is
|
||||
// terminal for the same purpose: a straggling progress-only batch must not
|
||||
// reopen it, while a real terminal observation still wins.
|
||||
var existingStatus = existing.GetString("Status");
|
||||
if ((existingStatus == "Succeeded" || existingStatus == "Failed") &&
|
||||
if ((existingStatus == "Succeeded" || existingStatus == "Failed" || existingStatus == "Incomplete") &&
|
||||
summary.Status != "Succeeded" && summary.Status != "Failed")
|
||||
{
|
||||
summary.Status = existingStatus;
|
||||
@@ -525,6 +527,66 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return sets.Contains(appId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the session's app rows that never reached a terminal state: Status
|
||||
/// "InProgress" (or missing) with no CompletedAt becomes "Incomplete" — terminal,
|
||||
/// non-failure, outcome unknown. Runs at the terminal seam, again after the batch that
|
||||
/// carries the agent's shutdown (rows opened after the verdict), and from the 30-day
|
||||
/// sweep. Idempotent + fail-soft; a real terminal arriving later still wins through the
|
||||
/// sticky-status reconcile. Returns the number of rows closed.
|
||||
/// </summary>
|
||||
public async Task<int> CloseOpenAppInstallsForSessionAsync(string tenantId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
|
||||
var filter = $"PartitionKey eq '{tenantId}' and RowKey ge '{sessionId}_' and RowKey lt '{sessionId}`'";
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
select: new[] { "PartitionKey", "RowKey", "Status", "CompletedAt" });
|
||||
|
||||
var closed = 0;
|
||||
await foreach (var row in query)
|
||||
{
|
||||
if (!ShouldCloseAsIncomplete(row)) continue;
|
||||
|
||||
var update = new TableEntity(row.PartitionKey, row.RowKey)
|
||||
{
|
||||
["Status"] = "Incomplete"
|
||||
};
|
||||
await tableClient.UpsertEntityAsync(update, TableUpdateMode.Merge);
|
||||
closed++;
|
||||
}
|
||||
|
||||
if (closed > 0)
|
||||
_logger.LogInformation(
|
||||
"Session {SessionId}: closed {Count} app rows as Incomplete (no terminal state when observation ended)",
|
||||
sessionId, closed);
|
||||
return closed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Closing open app rows failed for session {SessionId} (fail-soft)", sessionId);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure per-row predicate behind <see cref="CloseOpenAppInstallsForSessionAsync"/> —
|
||||
/// internal static so the contract is pinned by unit tests: only a row that is still
|
||||
/// open (Status missing, empty or "InProgress", no CompletedAt) closes; every terminal
|
||||
/// row, including an already-closed Incomplete one, is left alone (idempotency).
|
||||
/// </summary>
|
||||
internal static bool ShouldCloseAsIncomplete(TableEntity row)
|
||||
{
|
||||
if (row.GetDateTimeOffset("CompletedAt").HasValue) return false;
|
||||
var status = row.GetString("Status");
|
||||
return string.IsNullOrEmpty(status) || status == "InProgress";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets app install summaries for a tenant (fleet-level metrics). When <paramref name="sinceUtc"/>
|
||||
/// is supplied, a server-side <c>StartedAt ge</c> filter is applied so a windowed view (e.g. the
|
||||
|
||||
@@ -104,6 +104,42 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a Telegram notification when a tenant admin submits a custom rule for the
|
||||
/// community pool. The submission id comes first — it is the handle for the review.
|
||||
/// Best-effort — silently no-ops if the webhook URL is not configured.
|
||||
/// </summary>
|
||||
public async Task SendRuleSubmissionAsync(string submissionId, string tenantId, string submittedBy, string ruleKind, string ruleId, string title, string comment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhookUrl = await GetWebhookUrlAsync();
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
_logger.LogDebug("Telegram webhook URL not configured — skipping rule submission notification");
|
||||
return;
|
||||
}
|
||||
|
||||
var commentLine = string.IsNullOrWhiteSpace(comment) ? "" : $"\nComment: {comment}";
|
||||
var payload = new
|
||||
{
|
||||
chat_id = "-1003632442830",
|
||||
text = $"New Rule Submission {submissionId}\nRule: {ruleId} \"{title}\" ({ruleKind})\nTenantID: {tenantId}\nBy: {submittedBy}{commentLine}"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
_logger.LogInformation("Telegram rule submission notification sent for {SubmissionId}", submissionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send Telegram rule submission notification for {SubmissionId}", submissionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a Telegram notification when a tenant admin submits a diag-files-only report
|
||||
/// (no session context). Best-effort — silently no-ops if the webhook URL is not configured.
|
||||
|
||||
@@ -35,6 +35,8 @@ public static class TenantNotificationAudienceCatalog
|
||||
["rule_frequency_regression"] = NotificationAudience.Admin,
|
||||
// App-version duration regression radar: packaging/rollout is an admin concern.
|
||||
["app_version_duration_regression"] = NotificationAudience.Admin,
|
||||
// Community rule submission decided (approved/declined): only admins submit, only admins act.
|
||||
["rule_submission_decided"] = NotificationAudience.Admin,
|
||||
};
|
||||
|
||||
public static NotificationAudience Resolve(string? type)
|
||||
|
||||
+132
-132
@@ -34,6 +34,16 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -507,9 +517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -525,13 +535,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -547,20 +557,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -570,9 +580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -586,9 +596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -602,9 +612,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -618,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -634,9 +644,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -650,9 +660,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -666,9 +676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -682,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -698,9 +708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -714,9 +724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -730,9 +740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -748,13 +758,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -770,13 +780,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -792,13 +802,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -814,13 +824,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -836,13 +846,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -858,13 +868,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -880,13 +890,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -902,17 +912,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -921,27 +931,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -951,9 +951,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -970,9 +970,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -989,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2507,9 +2507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -3470,9 +3470,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -3486,31 +3486,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.0",
|
||||
"sharp": "^0.35.4",
|
||||
"fast-uri": "^3.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Two request-gate contracts (2026-09-10):
|
||||
* - a token the backend refuses (401) is answered 401 + WWW-Authenticate error="invalid_token"
|
||||
* (spec: invalid or expired tokens MUST receive 401), cached like a deny, never 403 "not enabled";
|
||||
* - the caller context carries a cancellation signal that fires when the client closes the request
|
||||
* before the response is written (Streamable HTTP: closing the response stream cancels the request).
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { accessGuard } from '../access-guard.js';
|
||||
import { getCallerSignal } from '../client.js';
|
||||
|
||||
function makeToken(claims: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
return `${header}.${payload}.sig`;
|
||||
}
|
||||
|
||||
let seed = 0;
|
||||
/** A fresh, unexpired token per call — the guard caches verdicts per UPN+token. */
|
||||
function request(): Request {
|
||||
seed += 1;
|
||||
const token = makeToken({ upn: `user${seed}@contoso.com`, exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
return {
|
||||
headers: { host: 'mcp.example.com', authorization: `Bearer ${token}` },
|
||||
protocol: 'https',
|
||||
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
interface Outcome {
|
||||
nextCalled: boolean;
|
||||
status: number | null;
|
||||
body: unknown;
|
||||
headers: Record<string, string>;
|
||||
/** The caller context's signal, read INSIDE next(). */
|
||||
signal?: AbortSignal;
|
||||
res: { writableFinished: boolean };
|
||||
/** Emits the response's 'close' event the way Node does when the socket goes away. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function run(req: Request): Promise<Outcome> {
|
||||
return new Promise((resolve) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const closeHandlers: Array<() => void> = [];
|
||||
let status: number | null = null;
|
||||
let settled = false;
|
||||
const state = { writableFinished: false };
|
||||
const close = () => closeHandlers.forEach((h) => h());
|
||||
const finish = (o: Omit<Outcome, 'res' | 'close'>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ ...o, res: state, close });
|
||||
};
|
||||
const res = {
|
||||
get writableFinished() { return state.writableFinished; },
|
||||
setHeader(name: string, value: string) { headers[name] = String(value); return this; },
|
||||
status(code: number) { status = code; return this; },
|
||||
json(payload: unknown) { finish({ nextCalled: false, status, body: payload, headers }); return this; },
|
||||
on(event: string, handler: () => void) { if (event === 'close') closeHandlers.push(handler); return this; },
|
||||
} as unknown as Response;
|
||||
const next: NextFunction = () => finish({ nextCalled: true, status, body: undefined, headers, signal: getCallerSignal() });
|
||||
accessGuard(req, res, next);
|
||||
setTimeout(() => finish({ nextCalled: false, status, body: undefined, headers }), 2_000);
|
||||
});
|
||||
}
|
||||
|
||||
function stubBackend(status: number, body: unknown) {
|
||||
const fn = vi.fn(async () => ({ status, text: async () => JSON.stringify(body) } as unknown as Response));
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('accessGuard — a backend 401 becomes 401 + challenge', () => {
|
||||
it('answers 401 with error="invalid_token" and the backend reason, not 403', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
stubBackend(401, { error: 'Token signature is invalid', code: 'unauthorized' });
|
||||
const out = await run(request());
|
||||
expect(out.nextCalled).toBe(false);
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.headers['WWW-Authenticate']).toContain('error="invalid_token"');
|
||||
expect(out.headers['WWW-Authenticate']).toContain('oauth-protected-resource/mcp');
|
||||
expect(out.body).toMatchObject({ error: 'Invalid token', reason: 'Token signature is invalid' });
|
||||
});
|
||||
|
||||
it('caches the rejection per token: a replay costs no second backend call and stays 401', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const fetchMock = stubBackend(401, { error: 'Token expired' });
|
||||
const req = request();
|
||||
const first = await run(req);
|
||||
const second = await run(req);
|
||||
expect(first.status).toBe(401);
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.headers['WWW-Authenticate']).toContain('error="invalid_token"');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a 401 without an envelope body at 401 with a generic reason', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
stubBackend(401, 'Unauthorized');
|
||||
const out = await run(request());
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.body).toMatchObject({ reason: 'The backend rejected the token' });
|
||||
});
|
||||
|
||||
it('keeps a genuine authorization deny (200, allowed:false) at 403 without a challenge', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
stubBackend(200, { allowed: false, upn: 'x', accessGrant: '', reason: 'not on the MCP allowlist' });
|
||||
const out = await run(request());
|
||||
expect(out.status).toBe(403);
|
||||
expect(out.headers['WWW-Authenticate']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessGuard — cancellation signal', () => {
|
||||
it('hands the caller context a signal that fires when the client closes the request early', async () => {
|
||||
stubBackend(200, { allowed: true, upn: 'x', accessGrant: 'ok' });
|
||||
const out = await run(request());
|
||||
expect(out.nextCalled).toBe(true);
|
||||
expect(out.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(out.signal?.aborted).toBe(false);
|
||||
out.close();
|
||||
expect(out.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not fire when the response completed normally before the socket closed', async () => {
|
||||
stubBackend(200, { allowed: true, upn: 'x', accessGrant: 'ok' });
|
||||
const out = await run(request());
|
||||
out.res.writableFinished = true;
|
||||
out.close();
|
||||
expect(out.signal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ function runGuard(req: Request): Promise<Outcome> {
|
||||
setHeader() { return this as Response; },
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json() { done({ nextCalled: false, status: statusCode }); return this as Response; },
|
||||
on() { return this as Response; },
|
||||
};
|
||||
const next: NextFunction = () => done({ nextCalled: true, status: statusCode });
|
||||
accessGuard(req, res as Response, next);
|
||||
|
||||
@@ -68,6 +68,10 @@ function mockRes(): CapturedRes {
|
||||
captured.body = payload;
|
||||
return this as Response;
|
||||
},
|
||||
// The guard subscribes to 'close' for the cancellation signal; nothing here ever emits it.
|
||||
on() {
|
||||
return this as Response;
|
||||
},
|
||||
};
|
||||
captured.res = res as Response;
|
||||
return captured;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Client cancellation reaches the backend: the caller context's signal (aborted by the access
|
||||
* guard when the client closes the request) is folded into every apiFetch attempt and into the
|
||||
* automatic 429/503 retry wait, so an abandoned tool call stops its backend work at once.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { apiFetch, runWithCaller } from '../client.js';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function caller(signal: AbortSignal) {
|
||||
return { token: 'test-token', isGlobalAdmin: true, isGlobalReader: false, signal };
|
||||
}
|
||||
|
||||
describe('apiFetch — caller cancellation', () => {
|
||||
it("aborts the in-flight backend fetch when the caller's signal fires", async () => {
|
||||
let seen: AbortSignal | undefined;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => new Promise<Response>((_resolve, reject) => {
|
||||
seen = init?.signal ?? undefined;
|
||||
seen?.addEventListener('abort', () => reject(seen?.reason), { once: true });
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
const pending = runWithCaller(caller(controller.signal), () => apiFetch<unknown>('/api/sessions'));
|
||||
|
||||
expect(seen).toBeDefined();
|
||||
expect(seen?.aborted).toBe(false);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(seen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('ends the automatic retry wait at once on cancellation instead of sleeping out Retry-After', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'retry-after' ? '5' : null) },
|
||||
text: async () => '',
|
||||
} as unknown as Response);
|
||||
const controller = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = runWithCaller(caller(controller.signal), () => apiFetch<unknown>('/api/sessions'));
|
||||
setTimeout(() => controller.abort(), 20);
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(Date.now() - started).toBeLessThan(2000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain per-attempt timeout when the caller context carries no signal', async () => {
|
||||
let seen: AbortSignal | undefined;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
seen = init?.signal ?? undefined;
|
||||
return { ok: true, status: 200, headers: { get: () => null }, text: async () => '{"ok":true}' } as unknown as Response;
|
||||
});
|
||||
await runWithCaller({ token: 'test-token', isGlobalAdmin: true }, () => apiFetch<unknown>('/api/sessions'));
|
||||
expect(seen).toBeDefined();
|
||||
expect(seen?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The per-argument echo policy a tool declares at its withToolTelemetry call site governs BOTH
|
||||
* sinks that quote the arguments: the tool_call log line and the "Parameters used" block of an
|
||||
* error result (whose first lines the log quotes). Without this, a clear-text config value masked
|
||||
* in the args summary still reached the container log through the error text.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toolError } from '../tools/error-handler.js';
|
||||
import { createToolCallContext, runWithToolCallContext } from '../client.js';
|
||||
|
||||
const TENANT = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const SECRET = 'https://hooks.example/services/secret-token-123';
|
||||
|
||||
function errorText(result: unknown): string {
|
||||
return (result as { content: Array<{ text: string }> }).content[0].text;
|
||||
}
|
||||
|
||||
describe('toolError — parameter echo honours the call\'s ArgPolicy', () => {
|
||||
it("renders only the field NAMES of a 'keys' argument, so no config value enters the error text", () => {
|
||||
const args = { tenantId: TENANT, fields: { teamsWebhookUrl: SECRET, dataRetentionDays: 4242 }, reason: 'rotate' };
|
||||
const result = runWithToolCallContext(
|
||||
createToolCallContext('update_tenant_config', 'cid', { fields: 'keys' }),
|
||||
() => toolError('update_tenant_config', args, new Error('boom')),
|
||||
);
|
||||
const text = errorText(result);
|
||||
expect(text).toContain('**Parameters used**');
|
||||
expect(text).toContain('fields: teamsWebhookUrl,dataRetentionDays');
|
||||
expect(text).toContain('reason: rotate');
|
||||
expect(text).not.toContain(SECRET);
|
||||
expect(text).not.toContain('4242');
|
||||
});
|
||||
|
||||
it("omits a 'drop' argument entirely", () => {
|
||||
const result = runWithToolCallContext(
|
||||
createToolCallContext('t', 'cid', { secret: 'drop' }),
|
||||
() => toolError('t', { secret: 'hunter2', q: 'x' }, new Error('boom')),
|
||||
);
|
||||
expect(errorText(result)).not.toContain('hunter2');
|
||||
expect(errorText(result)).not.toContain('secret');
|
||||
expect(errorText(result)).toContain('q: x');
|
||||
});
|
||||
|
||||
it('renders values verbatim (size-capped) without a policy — the pre-existing behaviour', () => {
|
||||
const text = errorText(toolError('search_sessions', { status: 'failed', pageSize: 50, days: null }, new Error('boom')));
|
||||
expect(text).toContain('status: failed');
|
||||
expect(text).toContain('pageSize: 50');
|
||||
expect(text).not.toContain('days');
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,7 @@ describe('revert_tenant_config — request shape', () => {
|
||||
apiFetchMock.mockResolvedValueOnce({ success: true });
|
||||
await captureToolHandlers(true, true).revert_tenant_config({
|
||||
tenantId: TENANT,
|
||||
backupId: '20260910T000000Z_abcd1234',
|
||||
reason: 'undo bad change',
|
||||
});
|
||||
|
||||
@@ -108,7 +109,7 @@ describe('revert_tenant_config — request shape', () => {
|
||||
expect(path).toBe(`/api/config/${TENANT}/revert`);
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body as string)).toEqual({
|
||||
backupId: undefined,
|
||||
backupId: '20260910T000000Z_abcd1234',
|
||||
includeProtectedFields: false,
|
||||
reason: 'undo bad change',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The Origin gate in front of the dual-era MCP entry, driven over loopback HTTP the way index.ts
|
||||
* mounts it (express.json → originGuard → handler): a foreign Origin is refused with 403 on a 2025
|
||||
* initialize and on a 2026-07-28 request alike; the server's own origin and no Origin pass.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import { createMcpRequestHandler } from '../mcp-http.js';
|
||||
import { createServerForCaller, type ServerDeps } from '../mcp-server-factory.js';
|
||||
import { runWithCaller } from '../client.js';
|
||||
import { originGuard } from '../origin-guard.js';
|
||||
|
||||
const DEPS: ServerDeps = { serverVersion: '0.0.0-test', knowledgeBase: undefined, eventTypeIndex: undefined, docs: undefined };
|
||||
|
||||
let httpServer: Server;
|
||||
let url: URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use('/mcp', express.json({ limit: '256kb' }));
|
||||
app.use('/mcp', originGuard);
|
||||
const handler = createMcpRequestHandler(() => createServerForCaller(DEPS), {});
|
||||
app.post('/mcp', (req, res) =>
|
||||
runWithCaller({ token: 'test-token', isGlobalAdmin: true, isGlobalReader: false, upn: 'caller@contoso.com' }, () => handler(req, res)),
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer = app.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = httpServer.address();
|
||||
if (typeof addr !== 'object' || addr === null) throw new Error('no address');
|
||||
url = new URL(`http://127.0.0.1:${addr.port}/mcp`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const LEGACY_INITIALIZE = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'origin-test', version: '1.0.0' } },
|
||||
};
|
||||
|
||||
const MODERN_TOOLS_LIST = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
params: {
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientInfo': { name: 'origin-test', version: '1.0.0' },
|
||||
'io.modelcontextprotocol/clientCapabilities': {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function post(body: unknown, headers: Record<string, string>): Promise<Response> {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('Origin gate on /mcp — both protocol eras', () => {
|
||||
it('refuses a foreign Origin on a 2025 initialize: 403 + JSON-RPC error without id, and a log line', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = await post(LEGACY_INITIALIZE, { origin: 'https://attacker.example' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ jsonrpc: '2.0', error: { code: -32600 }, id: null });
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('403 origin-rejected origin=https://attacker.example method=initialize'));
|
||||
});
|
||||
|
||||
it('refuses a foreign Origin on a 2026-07-28 request', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = await post(MODERN_TOOLS_LIST, {
|
||||
origin: 'https://attacker.example',
|
||||
'mcp-protocol-version': '2026-07-28',
|
||||
'mcp-method': 'tools/list',
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("serves the server's own origin", async () => {
|
||||
const res = await post(LEGACY_INITIALIZE, { origin: url.origin, 'mcp-protocol-version': '2025-11-25' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves a request without Origin (every non-browser client)', async () => {
|
||||
const res = await post(LEGACY_INITIALIZE, { 'mcp-protocol-version': '2025-11-25' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* OAuthStateSigningKey boot guard: production refuses to boot without the key (the signed
|
||||
* client_ids and the OAuth state derive from it and must survive restarts and replicas); the
|
||||
* per-process random fallback exists for development and tests only; a short key fails everywhere.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// oauth.ts throws at import unless the Entra client id is present.
|
||||
process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000';
|
||||
const { loadSigningKey } = await import('../oauth.js');
|
||||
|
||||
const KEY = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
describe('loadSigningKey', () => {
|
||||
it('decodes a valid base64 key of 32+ bytes in production', () => {
|
||||
expect(loadSigningKey(KEY, 'production').length).toBe(32);
|
||||
});
|
||||
|
||||
it('refuses to boot in production without a key', () => {
|
||||
expect(() => loadSigningKey(undefined, 'production')).toThrow('OAuthStateSigningKey is not set');
|
||||
expect(() => loadSigningKey('', 'production')).toThrow('OAuthStateSigningKey is not set');
|
||||
});
|
||||
|
||||
it('falls back to a fresh random key outside production', () => {
|
||||
const a = loadSigningKey(undefined, 'test');
|
||||
const b = loadSigningKey(undefined, undefined);
|
||||
expect(a.length).toBe(32);
|
||||
expect(b.length).toBe(32);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a key shorter than 32 bytes in every environment', () => {
|
||||
expect(() => loadSigningKey(Buffer.alloc(16).toString('base64'), 'test')).toThrow('32 bytes');
|
||||
expect(() => loadSigningKey('not base64 at all', 'production')).toThrow('32 bytes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* /oauth/token relays Entra's error body through an allowlist: the OAuth error code, the human
|
||||
* message (cut before the trace-id tail, capped) and the numeric AADSTS codes. Trace, correlation,
|
||||
* timestamp, error_uri and claims never reach an unauthenticated caller (review R-5).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// oauth.ts throws at import unless the Entra client id is present.
|
||||
process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000';
|
||||
const { sanitizeTokenErrorBody } = await import('../oauth.js');
|
||||
|
||||
const ENTRA_INVALID_GRANT = {
|
||||
error: 'invalid_grant',
|
||||
error_description:
|
||||
'AADSTS70008: The provided authorization code or refresh token has expired due to inactivity. ' +
|
||||
'Send a new interactive authorization request for this user and resource. Trace ID: 5d1a2b3c-0000-4000-8000-000000000000\r\n' +
|
||||
'Correlation ID: 7e9f0a1b-0000-4000-8000-000000000000\r\nTimestamp: 2026-09-10 00:00:00Z',
|
||||
error_codes: [70008],
|
||||
timestamp: '2026-09-10 00:00:00Z',
|
||||
trace_id: '5d1a2b3c-0000-4000-8000-000000000000',
|
||||
correlation_id: '7e9f0a1b-0000-4000-8000-000000000000',
|
||||
error_uri: 'https://login.microsoftonline.com/error?code=70008',
|
||||
claims: '{"access_token":{"capolids":{"essential":true,"values":["x"]}}}',
|
||||
};
|
||||
|
||||
describe('sanitizeTokenErrorBody', () => {
|
||||
it('keeps the OAuth error, the message before the trace tail, and the numeric codes only', () => {
|
||||
expect(sanitizeTokenErrorBody(ENTRA_INVALID_GRANT)).toEqual({
|
||||
error: 'invalid_grant',
|
||||
error_description:
|
||||
'AADSTS70008: The provided authorization code or refresh token has expired due to inactivity. ' +
|
||||
'Send a new interactive authorization request for this user and resource.',
|
||||
error_codes: [70008],
|
||||
});
|
||||
});
|
||||
|
||||
it('never carries trace, correlation, timestamp, error_uri or claims', () => {
|
||||
const out = sanitizeTokenErrorBody(ENTRA_INVALID_GRANT);
|
||||
for (const key of ['trace_id', 'correlation_id', 'timestamp', 'error_uri', 'claims']) expect(out).not.toHaveProperty(key);
|
||||
expect(JSON.stringify(out)).not.toContain('5d1a2b3c');
|
||||
expect(JSON.stringify(out)).not.toContain('7e9f0a1b');
|
||||
});
|
||||
|
||||
it('maps an unknown error code to invalid_request', () => {
|
||||
expect(sanitizeTokenErrorBody({ error: 'something_entra_specific', error_description: 'x' }).error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('caps a long description', () => {
|
||||
const out = sanitizeTokenErrorBody({ error: 'invalid_request', error_description: 'y'.repeat(1000) });
|
||||
expect((out.error_description as string).length).toBe(303);
|
||||
expect(out.error_description as string).toMatch(/\.\.\.$/);
|
||||
});
|
||||
|
||||
it('tolerates a non-object body and non-numeric codes', () => {
|
||||
expect(sanitizeTokenErrorBody('not json')).toEqual({ error: 'invalid_request' });
|
||||
expect(sanitizeTokenErrorBody({ error: 'invalid_client', error_codes: ['7000218', 7000218] })).toEqual({
|
||||
error: 'invalid_client',
|
||||
error_codes: [7000218],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Origin gate on /mcp (spec 2026-07-28 Streamable HTTP "Security & Endpoint": a present, invalid
|
||||
* Origin MUST be answered 403). Non-browser clients send no Origin and pass; only the server's own
|
||||
* origin passes with one; everything else — foreign hosts, scheme downgrades, suffix tricks, the
|
||||
* opaque `null` — is refused before any token work.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { isAllowedOrigin, originGuard } from '../origin-guard.js';
|
||||
|
||||
const SELF = 'https://mcp.example.com';
|
||||
|
||||
describe('isAllowedOrigin', () => {
|
||||
it('passes a request without Origin (every non-browser MCP client)', () => {
|
||||
expect(isAllowedOrigin(undefined, SELF)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the server's own origin, port-normalised and case-insensitive on the host", () => {
|
||||
expect(isAllowedOrigin('https://mcp.example.com', SELF)).toBe(true);
|
||||
expect(isAllowedOrigin('https://MCP.example.com:443', SELF)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://evil.example',
|
||||
'http://mcp.example.com',
|
||||
'https://mcp.example.com.evil.example',
|
||||
'https://mcp.example.com:8443',
|
||||
'null',
|
||||
'not a url',
|
||||
'',
|
||||
])('rejects a present, invalid Origin: %j', (origin) => {
|
||||
expect(isAllowedOrigin(origin, SELF)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('originGuard middleware', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function run(origin?: string) {
|
||||
const req = {
|
||||
headers: { host: 'mcp.example.com', ...(origin !== undefined ? { origin } : {}) },
|
||||
protocol: 'https',
|
||||
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
|
||||
} as unknown as Request;
|
||||
let status: number | null = null;
|
||||
let body: unknown;
|
||||
const res = {
|
||||
status(code: number) { status = code; return this; },
|
||||
json(payload: unknown) { body = payload; return this; },
|
||||
} as unknown as Response;
|
||||
const next = vi.fn();
|
||||
originGuard(req, res, next);
|
||||
return { status: status as number | null, body, next };
|
||||
}
|
||||
|
||||
it('lets a request without Origin through untouched', () => {
|
||||
const { status, next } = run();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
|
||||
it("lets the server's own origin through", () => {
|
||||
const { status, next } = run('https://mcp.example.com');
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
|
||||
it('answers 403 with a JSON-RPC error without id for a foreign origin, and logs it', () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { status, body, next } = run('https://attacker.example');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual({ jsonrpc: '2.0', error: { code: -32600, message: 'Origin not allowed' }, id: null });
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('[mcp-auth] 403 origin-rejected origin=https://attacker.example method=tools/list'));
|
||||
});
|
||||
|
||||
it('treats the opaque "null" origin as present and invalid', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { status, next } = run('null');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('renders a hostile origin value log-safe (control characters replaced, length capped)', () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
run('https://x.example/\r\nINJECTED' + 'a'.repeat(500));
|
||||
const line = log.mock.calls[0][0] as string;
|
||||
expect(line).not.toContain('\n');
|
||||
expect(line.length).toBeLessThan(320);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* query_raw_events pageSize follows the effective projection: a page that carries DataJson is
|
||||
* bounded by rows × payload (measured p90 3.3 KB, outliers 21-29 KB per row), so it gets a
|
||||
* smaller default and a ceiling of 100, refused BEFORE the backend does the work; a lean page
|
||||
* keeps the schema's 1000. A nextLink is judged by its echoed fields.
|
||||
*
|
||||
* The handler reaches the backend through scanWithTimeoutFallback (client.ts), so that is the
|
||||
* seam the test observes: its first argument is the path the backend would receive.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { scanMock } = vi.hoisted(() => ({ scanMock: vi.fn() }));
|
||||
|
||||
vi.mock('../client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client.js')>();
|
||||
return { ...actual, scanWithTimeoutFallback: scanMock };
|
||||
});
|
||||
|
||||
import { registerAdminTools, rawEventsPayloadIncluded, RAW_EVENTS_PAYLOAD_PAGE_DEFAULT, RAW_EVENTS_PAYLOAD_PAGE_MAX } from '../tools/admin.js';
|
||||
import { DEFAULT_FIRST_PAGE_SIZE } from '../client.js';
|
||||
|
||||
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
||||
|
||||
function queryRawEvents(): Handler {
|
||||
const handlers: Record<string, Handler> = {};
|
||||
const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { handlers[name] = handler; } };
|
||||
registerAdminTools(fake as never, true, true, false);
|
||||
return handlers.query_raw_events;
|
||||
}
|
||||
|
||||
const PAGE = { count: 1, events: [{ EventType: 'app_install_failed' }] };
|
||||
|
||||
function sentPath(): string {
|
||||
return scanMock.mock.calls[0][0] as string;
|
||||
}
|
||||
|
||||
function sentPageSize(): string | null {
|
||||
const path = sentPath();
|
||||
return new URLSearchParams(path.slice(path.indexOf('?') + 1)).get('pageSize');
|
||||
}
|
||||
|
||||
const FULL_ROWS_NEXTLINK = '/api/raw/events?eventType=app_install_failed&pageSize=1000&continuation=c1';
|
||||
const LEAN_NEXTLINK = '/api/raw/events?eventType=app_install_failed&fields=EventType%2CSeverity%2COccurredUtc&pageSize=1000&continuation=c1';
|
||||
|
||||
describe('rawEventsPayloadIncluded', () => {
|
||||
it('follows an explicit fields list, case-insensitively', () => {
|
||||
expect(rawEventsPayloadIncluded('PartitionKey,DataJson', undefined, false)).toBe(true);
|
||||
expect(rawEventsPayloadIncluded('EventType, datajson', undefined, false)).toBe(true);
|
||||
expect(rawEventsPayloadIncluded('EventType,Message', undefined, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('a filtered first page without fields carries the payload; an unfiltered one is lean by default', () => {
|
||||
expect(rawEventsPayloadIncluded(undefined, undefined, true)).toBe(true);
|
||||
expect(rawEventsPayloadIncluded(undefined, undefined, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('judges a nextLink by its echoed fields — none echoed means full rows', () => {
|
||||
expect(rawEventsPayloadIncluded(undefined, FULL_ROWS_NEXTLINK, false)).toBe(true);
|
||||
expect(rawEventsPayloadIncluded(undefined, LEAN_NEXTLINK, false)).toBe(false);
|
||||
expect(rawEventsPayloadIncluded(undefined, '/api/raw/events?fields=EventType%2CDataJson&continuation=c1', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query_raw_events — pageSize follows the payload projection', () => {
|
||||
beforeEach(() => {
|
||||
scanMock.mockReset();
|
||||
scanMock.mockResolvedValue(PAGE);
|
||||
});
|
||||
|
||||
it('refuses pageSize 1000 on a filtered read (full rows) before any backend call', async () => {
|
||||
const result = await queryRawEvents()({ eventType: 'app_install_failed', pageSize: 1000 });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain(`exceeds the maximum of ${RAW_EVENTS_PAYLOAD_PAGE_MAX}`);
|
||||
expect(scanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an explicit DataJson projection above the ceiling', async () => {
|
||||
const result = await queryRawEvents()({ fields: 'EventType,DataJson', pageSize: RAW_EVENTS_PAYLOAD_PAGE_MAX + 1 });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(scanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the ceiling itself with the payload', async () => {
|
||||
const result = await queryRawEvents()({ eventType: 'app_install_failed', pageSize: RAW_EVENTS_PAYLOAD_PAGE_MAX });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(sentPageSize()).toBe(String(RAW_EVENTS_PAYLOAD_PAGE_MAX));
|
||||
});
|
||||
|
||||
it('keeps the large page for a lean projection', async () => {
|
||||
const result = await queryRawEvents()({ eventType: 'app_install_failed', fields: 'EventType,Severity,OccurredUtc', pageSize: 1000 });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(sentPageSize()).toBe('1000');
|
||||
});
|
||||
|
||||
it('defaults a payload page to the smaller size and a lean page to the tool default', async () => {
|
||||
await queryRawEvents()({ eventType: 'app_install_failed' });
|
||||
expect(sentPageSize()).toBe(String(RAW_EVENTS_PAYLOAD_PAGE_DEFAULT));
|
||||
|
||||
scanMock.mockClear();
|
||||
await queryRawEvents()({});
|
||||
expect(sentPageSize()).toBe(String(DEFAULT_FIRST_PAGE_SIZE));
|
||||
});
|
||||
|
||||
it('refuses a nextLink whose embedded pageSize exceeds the ceiling for full rows', async () => {
|
||||
const result = await queryRawEvents()({ continuation: FULL_ROWS_NEXTLINK });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('overrides the value embedded in a nextLink');
|
||||
expect(scanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an explicit smaller pageSize override the nextLink and pass', async () => {
|
||||
const result = await queryRawEvents()({ continuation: FULL_ROWS_NEXTLINK, pageSize: RAW_EVENTS_PAYLOAD_PAGE_MAX });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(sentPath()).toContain('continuation=c1');
|
||||
expect(sentPageSize()).toBe(String(RAW_EVENTS_PAYLOAD_PAGE_MAX));
|
||||
});
|
||||
|
||||
it('keeps the embedded 1000 of a lean nextLink', async () => {
|
||||
const result = await queryRawEvents()({ continuation: LEAN_NEXTLINK });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(sentPageSize()).toBe('1000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* revert_tenant_config takes an explicit backupId — no "latest" default. Every revert snapshots
|
||||
* the current state first, so a retried revert without an id would restore the snapshot the
|
||||
* previous revert had just created and undo it. With a fixed id the same call is a no-op twice.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
vi.mock('../client.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../client.js')>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { registerAdminTools } from '../tools/admin.js';
|
||||
|
||||
interface ToolDef { description: string; inputSchema: Record<string, z.ZodTypeAny> }
|
||||
|
||||
function definitionOf(tool: string): ToolDef {
|
||||
let found: ToolDef | undefined;
|
||||
const fake = { registerTool: (name: string, def: ToolDef) => { if (name === tool) found = def; } };
|
||||
registerAdminTools(fake as never, true, true, false);
|
||||
if (!found) throw new Error(`${tool} not registered`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const TENANT = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
describe('revert_tenant_config — backupId is required', () => {
|
||||
it('rejects a revert without backupId or with an empty one; accepts a named snapshot', () => {
|
||||
const schema = z.object(definitionOf('revert_tenant_config').inputSchema);
|
||||
expect(schema.safeParse({ tenantId: TENANT, reason: 'undo' }).success).toBe(false);
|
||||
expect(schema.safeParse({ tenantId: TENANT, reason: 'undo', backupId: '' }).success).toBe(false);
|
||||
expect(schema.safeParse({ tenantId: TENANT, reason: 'undo', backupId: '20260910T000000Z_abcd1234' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('explains the rule in the description and no longer advertises a latest default', () => {
|
||||
const { description } = definitionOf('revert_tenant_config');
|
||||
expect(description).toContain('backupId is required');
|
||||
expect(description).not.toContain('latest by default');
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('buildSessionCoverage — IME log tracker', () => {
|
||||
ev('ime_tracker_degraded', 50, { file: 'AppWorkload.log', lineBudgetBreaks: 1, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 }, { severity: 'Debug' }),
|
||||
]);
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 });
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1, overwriteRewinds: null, overwriteBytesReprocessed: null });
|
||||
expect(c.gaps[0]).toContain('skipped the remaining patterns on 7 line(s)');
|
||||
expect(c.gaps[0]).toContain('gave up on 2 line(s) after a regex timeout');
|
||||
expect(c.gaps[0]).toContain('dropped 1 oversized line(s)');
|
||||
@@ -143,9 +143,30 @@ describe('buildSessionCoverage — IME log tracker', () => {
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
]);
|
||||
expect(c.imeTracker).toEqual({ degraded: false, sessionTotals: { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 } });
|
||||
expect(c.imeTracker).toEqual({ degraded: false, sessionTotals: { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: null, overwriteBytesReprocessed: null } });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports re-read overwritten log blocks as recovered data — a note, never a gap or degradation', () => {
|
||||
// Agents ≥ 2.0.1458: IME processes overwrite each other's bytes in AgentExecutor.log and
|
||||
// IntuneManagementExtension.log; the tracker re-reads the changed blocks and counts them.
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 8957, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 6, overwriteBytesReprocessed: 5120 }, { severity: 'Debug' }),
|
||||
]);
|
||||
expect(c.imeTracker.degraded).toBe(false);
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 8957, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 6, overwriteBytesReprocessed: 5120 });
|
||||
expect(c.imeTracker.note).toBe('6 log block(s) a concurrent IME process had overwritten were re-read (5 KB) — script results and exit codes in them are complete, not missing.');
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds no note when nothing was overwritten or the agent predates the counter', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 0, overwriteBytesReprocessed: 0 }),
|
||||
]);
|
||||
expect(c.imeTracker.note).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — collectors, upload, disk', () => {
|
||||
@@ -254,6 +275,70 @@ describe('buildSessionCoverage — diagnostics package', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — apps without a terminal state', () => {
|
||||
// The shutdown emit of the termination handler: what was still in flight when the agent left.
|
||||
const shutdownSummary = ev('app_tracking_summary', 900, { installingNames: ['Suite'], downloadingNames: [], pendingNames: ['Other'] });
|
||||
|
||||
it("names the apps still in flight in the agent's last summary on a terminal session", () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Succeeded' }, [
|
||||
ev('agent_started', 5),
|
||||
ev('app_tracking_summary', 300, { installingNames: ['Early'], downloadingNames: ['Later'] }),
|
||||
shutdownSummary,
|
||||
]);
|
||||
expect(c.apps).toEqual({ stillInstalling: ['Suite'], stillDownloading: [] });
|
||||
expect(c.gaps).toEqual([expect.stringContaining('1 app(s) still installing when the agent stopped observing (Suite)')]);
|
||||
});
|
||||
|
||||
it('reports nothing while the session is still live, even with apps in flight', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'InProgress' }, [ev('agent_started', 5), shutdownSummary]);
|
||||
expect(c.apps).toEqual({ stillInstalling: [], stillDownloading: [] });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it("stays silent when the last summary has nothing in flight (apps that never started are the starved rule's finding)", () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Failed' }, [
|
||||
ev('agent_started', 5),
|
||||
ev('app_tracking_summary', 900, { installingNames: [], pendingNames: ['Other'] }),
|
||||
]);
|
||||
expect(c.apps).toEqual({ stillInstalling: [], stillDownloading: [] });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — scripts without a terminal state', () => {
|
||||
// Shape of session 2ebcd480: the session's last event was a platform script's start line.
|
||||
// There is no shutdown summary for scripts — the stream itself is the evidence.
|
||||
const started = (policyId: string, at: number, scriptType = 'platform') => ev('script_started', at, { policyId, scriptType });
|
||||
const finished = (policyId: string, at: number, scriptType = 'platform') => ev('script_completed', at, { policyId, scriptType, exitCode: '0' });
|
||||
|
||||
it('names the scripts whose start line never got a result on a terminal session', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Succeeded' }, [
|
||||
ev('agent_started', 5),
|
||||
started('p-a', 900), finished('p-a', 903),
|
||||
started('p-b', 904), ev('script_failed', 907, { policyId: 'p-b', scriptType: 'platform' }),
|
||||
started('p-last', 910),
|
||||
]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [{ policyId: 'p-last', scriptType: 'platform' }] });
|
||||
expect(c.gaps).toEqual([expect.stringContaining('1 script(s) still running when the agent stopped observing (platform p-last)')]);
|
||||
});
|
||||
|
||||
it('reports nothing while the session is still live, even with a script in flight', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'InProgress' }, [ev('agent_started', 5), started('p-last', 910)]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [] });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('a start after its own earlier result is a second run and counts as still running; snake_case keys are read too', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Failed' }, [
|
||||
ev('agent_started', 5),
|
||||
finished('r1', 100, 'remediation'),
|
||||
ev('script_started', 200, { policy_id: 'r1', script_type: 'remediation' }),
|
||||
]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [{ policyId: 'r1', scriptType: 'remediation' }] });
|
||||
expect(c.gaps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverage wiring', () => {
|
||||
it('SUMMARY_EVENT_FIELDS carries every coverage slice, as data.<key> entries only', () => {
|
||||
const fields = SUMMARY_EVENT_FIELDS.split(',');
|
||||
@@ -261,6 +346,10 @@ describe('coverage wiring', () => {
|
||||
expect(f.startsWith('data.')).toBe(true);
|
||||
expect(fields).toContain(f);
|
||||
}
|
||||
// The scripts block also reads scriptType/script_type — they ride in the summary's own
|
||||
// list for the benign-detection guard, so pin them here rather than duplicating them.
|
||||
expect(fields).toContain('data.scriptType');
|
||||
expect(fields).toContain('data.script_type');
|
||||
expect(fields).not.toContain('data');
|
||||
});
|
||||
|
||||
@@ -271,6 +360,8 @@ describe('coverage wiring', () => {
|
||||
'agent_started', 'agent_late_start', 'historic_ime_replay_detected', 'ime_tracker_degraded', 'ime_pattern_hits',
|
||||
'collector_degraded', 'spool_pressure_detected', 'telemetry_upload_poisoned', 'telemetry_upload_blocked',
|
||||
'ingress_backpressure', 'disk_space_low', 'diagnostics_collecting', 'diagnostics_uploaded', 'diagnostics_upload_failed',
|
||||
'app_tracking_summary',
|
||||
'script_started', 'script_completed', 'script_failed',
|
||||
];
|
||||
expect([...COVERAGE_EVENT_TYPES].sort()).toEqual([...consulted].sort());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The tool_call line under the two 2026-09-10 result contracts: an overflow answer (page refused
|
||||
* by toolResultText) is logged at the size the page WOULD have had and as overCap; a call the
|
||||
* client abandoned is logged as cancelled, never as an error.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
async function loadTelemetry() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MCP_TOOL_LOGGING', 'true');
|
||||
return await import('../telemetry.js');
|
||||
}
|
||||
|
||||
function lastLoggedJson(spy: ReturnType<typeof vi.spyOn>): Record<string, unknown> {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
return JSON.parse(spy.mock.calls[spy.mock.calls.length - 1][0] as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('withToolTelemetry — overflow and cancellation', () => {
|
||||
it('logs an overflow answer at the size the page would have had, as overCap and isError', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry();
|
||||
const { toolResultText } = await import('../tools/shared.js');
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const page = { events: Array.from({ length: 50 }, (_, i) => ({ i, payload: 'x'.repeat(200) })) };
|
||||
|
||||
const result = await withToolTelemetry('query_raw_events', { eventType: 'x' }, () => toolResultText(page, 1_000));
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.overCap).toBe(true);
|
||||
expect(line.isError).toBe(true);
|
||||
expect(line.resultChars).toBe(JSON.stringify(page).length);
|
||||
expect(String(line.errorMessage)).toContain('"overflow":true');
|
||||
});
|
||||
|
||||
it('logs a call the client abandoned as cancelled, not as an error', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry();
|
||||
const { runWithCaller } = await import('../client.js');
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
|
||||
await runWithCaller({ token: 't', isGlobalAdmin: true, signal: controller.signal }, () =>
|
||||
withToolTelemetry('get_session', { sessionId: 's' }, () => {
|
||||
controller.abort();
|
||||
return { isError: true, content: [{ type: 'text', text: '**Timeout in get_session**' }] };
|
||||
}),
|
||||
);
|
||||
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.cancelled).toBe(true);
|
||||
expect(line.isError).toBe(false);
|
||||
expect(line.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a completed call without a cancelled field', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry();
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await withToolTelemetry('get_session', { sessionId: 's' }, () => ({ content: [{ type: 'text', text: '{}' }] }));
|
||||
expect(lastLoggedJson(spy)).not.toHaveProperty('cancelled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The response-size cap is enforced, not advisory: a page above the tool's cap is never sent —
|
||||
* the answer is an error naming what the page held and the exact pageSize that fits, computed from
|
||||
* THIS page's bytes per row. The oversized page's nextLink is withheld (following it would skip
|
||||
* rows), and the telemetry marker carries the size the page would have had.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toolResultText, OVERFLOW_META_KEY } from '../tools/shared.js';
|
||||
|
||||
const CAP = 10_000;
|
||||
|
||||
function rows(n: number, payloadChars: number): Array<{ id: number; payload: string }> {
|
||||
return Array.from({ length: n }, (_, i) => ({ id: i, payload: 'x'.repeat(payloadChars) }));
|
||||
}
|
||||
|
||||
function bodyOf(result: { content: Array<{ text: string }> }): Record<string, unknown> {
|
||||
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('toolResultText — hard cap', () => {
|
||||
it('sends a page under the cap unchanged, with the size hint and no overflow marker', () => {
|
||||
const data = { count: 3, events: rows(3, 100), nextLink: '/api/x?continuation=abc' };
|
||||
const r = toolResultText(data, CAP);
|
||||
expect(r.isError).toBeUndefined();
|
||||
expect(bodyOf(r)).toEqual(data);
|
||||
expect(r._meta['anthropic/maxResultSizeChars']).toBe(CAP);
|
||||
expect(r._meta[OVERFLOW_META_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a page over the cap: no rows, no nextLink, the pageSize that fits, the marker', () => {
|
||||
const data = { count: 100, events: rows(100, 500), nextLink: '/api/x?continuation=abc' };
|
||||
const chars = JSON.stringify(data).length;
|
||||
const r = toolResultText(data, CAP);
|
||||
|
||||
expect(r.isError).toBe(true);
|
||||
const body = bodyOf(r);
|
||||
expect(body.overflow).toBe(true);
|
||||
expect(body.responseChars).toBe(chars);
|
||||
expect(body.maxResultSizeChars).toBe(CAP);
|
||||
expect(body.rowsField).toBe('events');
|
||||
expect(body.rowsFetched).toBe(100);
|
||||
expect(body.rowsThatFit).toBe(Math.floor((CAP * 0.9) / (chars / 100)));
|
||||
expect(body.rowsThatFit as number).toBeGreaterThanOrEqual(1);
|
||||
expect(body.rowsThatFit as number).toBeLessThan(100);
|
||||
expect(body.events).toBeUndefined();
|
||||
expect(body.nextLink).toBeUndefined();
|
||||
expect(body.advice).toContain(`pageSize=${body.rowsThatFit}`);
|
||||
expect(r.content[0].text.length).toBeLessThan(CAP);
|
||||
expect(r._meta[OVERFLOW_META_KEY]).toEqual({ responseChars: chars });
|
||||
expect(r._meta['anthropic/maxResultSizeChars']).toBe(CAP);
|
||||
});
|
||||
|
||||
it('gives an honest advice: a re-send at rowsThatFit lands under the cap', () => {
|
||||
const fit = bodyOf(toolResultText({ events: rows(100, 500) }, CAP)).rowsThatFit as number;
|
||||
const again = toolResultText({ events: rows(fit, 500) }, CAP);
|
||||
expect(again.isError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never advises fewer than one row', () => {
|
||||
const body = bodyOf(toolResultText({ events: rows(2, CAP) }, CAP));
|
||||
expect(body.rowsThatFit).toBe(1);
|
||||
});
|
||||
|
||||
it('gives only narrowing advice when the page carries no list', () => {
|
||||
const body = bodyOf(toolResultText({ blob: 'x'.repeat(CAP + 1) }, CAP));
|
||||
expect(body.overflow).toBe(true);
|
||||
expect(body.rowsThatFit).toBeUndefined();
|
||||
expect(body.rowsField).toBeUndefined();
|
||||
expect(body.advice).toContain('Narrow the query');
|
||||
});
|
||||
|
||||
it('does not project a pageSize from a one-element list (the KQL tables[] shape)', () => {
|
||||
const body = bodyOf(toolResultText({ tables: [{ name: 'PrimaryResult', rows: rows(50, 300) }] }, CAP));
|
||||
expect(body.overflow).toBe(true);
|
||||
expect(body.rowsThatFit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('picks the largest top-level list when a page carries several', () => {
|
||||
const body = bodyOf(toolResultText({ warnings: ['a', 'b'], sessions: rows(60, 400) }, CAP));
|
||||
expect(body.rowsField).toBe('sessions');
|
||||
expect(body.rowsFetched).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
// fails — that failure is the deliberate prompt to decide its role placement.
|
||||
const GA_FULL = [
|
||||
'annotate_session',
|
||||
// Community rule submission write surface (strictGa): operator hard delete.
|
||||
'delete_rule_submission',
|
||||
'get_agent_efficiency_metrics',
|
||||
'get_api_usage',
|
||||
'get_app_install_metrics',
|
||||
@@ -132,6 +134,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_platform_metrics',
|
||||
'get_resource',
|
||||
'get_rule_stats',
|
||||
// Platform scope (GA + Global Reader): one community rule submission with its frozen rule.
|
||||
'get_rule_submission',
|
||||
'get_session',
|
||||
'get_session_diagnostics',
|
||||
'get_session_events',
|
||||
@@ -151,6 +155,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_verdict_calibration',
|
||||
'get_vulnerability_summary',
|
||||
'list_blocked_devices',
|
||||
// Platform scope (GA + Global Reader): community rule submissions across tenants.
|
||||
'list_rule_submissions',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
@@ -162,7 +168,10 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'query_raw_events',
|
||||
'query_raw_sessions',
|
||||
'query_table',
|
||||
// Community rule submission write surface (strictGa): the publish step and the decision.
|
||||
'reseed_rules_from_github',
|
||||
'revert_tenant_config',
|
||||
'review_rule_submission',
|
||||
'search_events',
|
||||
'search_knowledge',
|
||||
'search_sessions',
|
||||
@@ -200,12 +209,17 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
// reads annotations (list_session_annotations) but never writes the GA lane.
|
||||
const ANNOTATION_WRITE_GA_STRICT = ['annotate_session'];
|
||||
|
||||
// Community rule submission write surface: strictGa only — a read-only Global Reader
|
||||
// lists and reads submissions but never decides one or triggers the reseed that publishes.
|
||||
const RULE_SUBMISSION_WRITE_GA_STRICT = ['delete_rule_submission', 'reseed_rules_from_github', 'review_rule_submission'];
|
||||
|
||||
// Platform-only tools: a non-platform caller (tenant or delegated) gets no
|
||||
// cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and
|
||||
// CONFIG_WRITE_GA_STRICT (those are also platform-only) plus the curated
|
||||
// cross-tenant aggregates.
|
||||
const PLATFORM_ONLY = [
|
||||
'annotate_session',
|
||||
'delete_rule_submission',
|
||||
'get_agent_efficiency_metrics',
|
||||
'get_api_usage',
|
||||
'get_deployment_state',
|
||||
@@ -213,11 +227,13 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_ime_pattern_health',
|
||||
'get_ops_events',
|
||||
'get_platform_metrics',
|
||||
'get_rule_submission',
|
||||
'get_session_report_download',
|
||||
'get_tenant_config',
|
||||
'get_tenant_config_schema',
|
||||
'get_verdict_calibration',
|
||||
'list_blocked_devices',
|
||||
'list_rule_submissions',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
@@ -225,7 +241,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'list_tenants',
|
||||
'query_backend_logs',
|
||||
'query_table',
|
||||
'reseed_rules_from_github',
|
||||
'revert_tenant_config',
|
||||
'review_rule_submission',
|
||||
'update_tenant_config',
|
||||
];
|
||||
|
||||
@@ -246,7 +264,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
|
||||
it('Global Reader = GA minus the secret-bearing raw tools, the config-write surface and the annotation write (strictGa split)', () => {
|
||||
expect(registeredToolNames(true, false)).toEqual(
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT]));
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT, ...RULE_SUBMISSION_WRITE_GA_STRICT]));
|
||||
});
|
||||
|
||||
it('tenant user = GA minus all platform-only tools', () => {
|
||||
@@ -265,6 +283,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
['RAW_GA_STRICT', RAW_GA_STRICT],
|
||||
['CONFIG_WRITE_GA_STRICT', CONFIG_WRITE_GA_STRICT],
|
||||
['ANNOTATION_WRITE_GA_STRICT', ANNOTATION_WRITE_GA_STRICT],
|
||||
['RULE_SUBMISSION_WRITE_GA_STRICT', RULE_SUBMISSION_WRITE_GA_STRICT],
|
||||
['PLATFORM_ONLY', PLATFORM_ONLY],
|
||||
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
|
||||
['DELEGATED_ADDED', DELEGATED_ADDED],
|
||||
@@ -282,7 +301,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
const registeredDiff = registeredToolNames(true, true).filter((n) => !readerNames.includes(n)).sort();
|
||||
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(registeredDiff);
|
||||
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(
|
||||
[...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT].sort());
|
||||
[...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT, ...RULE_SUBMISSION_WRITE_GA_STRICT].sort());
|
||||
});
|
||||
|
||||
it('the secret-bearing raw tools are a subset of the platform-only tools', () => {
|
||||
|
||||
@@ -89,6 +89,9 @@ interface AccessCacheEntry {
|
||||
// scope and downgrade the caller to home-tenant-only routing.
|
||||
delegatedTenantIds?: string[];
|
||||
delegatedRole?: string;
|
||||
// The backend refused the TOKEN itself (401) — cached like a deny so a replayed bad token costs
|
||||
// one backend call per TTL, but answered 401 + challenge, never "not enabled".
|
||||
unauthenticated?: boolean;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@@ -109,6 +112,11 @@ interface AccessCheckResult {
|
||||
// routing bounded to this set; every delegated tool call must name one of these tenants.
|
||||
delegatedTenantIds?: string[];
|
||||
delegatedRole?: string;
|
||||
// The backend — the only party holding the JWKS — rejected the token (signature, issuer,
|
||||
// audience, lifetime). That is "invalid token": the spec says it MUST be answered 401 with a
|
||||
// WWW-Authenticate challenge so the client re-authenticates, not 403 ("insufficient
|
||||
// permissions"), which a client reads as final.
|
||||
unauthenticated?: boolean;
|
||||
// Distinguishes a genuine authorization denial (the backend reached a verdict
|
||||
// and said "no" — e.g. user not on the MCP whitelist) from an infrastructure
|
||||
// failure (backend unreachable / malformed response). Both fail closed, but
|
||||
@@ -161,6 +169,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
|
||||
isGlobalReader: cached.isGlobalReader,
|
||||
delegatedTenantIds: cached.delegatedTenantIds,
|
||||
delegatedRole: cached.delegatedRole,
|
||||
unauthenticated: cached.unauthenticated,
|
||||
// Only genuine backend verdicts are ever cached (infra errors return early
|
||||
// below without caching), so a cache hit is never an infra error.
|
||||
infraError: false,
|
||||
@@ -183,6 +192,22 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (res.status === 401) {
|
||||
// The token itself was refused (see AccessCheckResult.unauthenticated). The envelope's
|
||||
// message names the cause (expired, wrong audience, bad signature) for the log line.
|
||||
let reason = 'The backend rejected the token';
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<ApiErrorResponse>;
|
||||
if (typeof parsed.error === 'string' && parsed.error) reason = parsed.error;
|
||||
} catch {
|
||||
/* not an error envelope */
|
||||
}
|
||||
boundedSet(accessCache, cacheKey, {
|
||||
allowed: false, reason, isGlobalAdmin: false, isGlobalReader: false, unauthenticated: true,
|
||||
expiresAt: Date.now() + ACCESS_CACHE_TTL_MS,
|
||||
}, MAX_ACCESS_CACHE_ENTRIES);
|
||||
return { allowed: false, reason, isGlobalAdmin: false, isGlobalReader: false, infraError: false, unauthenticated: true };
|
||||
}
|
||||
if (!text) {
|
||||
console.error(`[access-guard] Backend returned empty body for ${upn} (status=${res.status})`);
|
||||
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false, infraError: true };
|
||||
@@ -451,6 +476,14 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
|
||||
return;
|
||||
}
|
||||
if (!result.allowed) {
|
||||
if (result.unauthenticated) {
|
||||
// Spec: an invalid or expired token MUST get 401 with a challenge, so the client
|
||||
// re-authenticates instead of reading a final "forbidden".
|
||||
console.error(`[mcp-auth] 401 backend-rejected-token (method=${rpcMethod}, upn=${upn})`);
|
||||
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`);
|
||||
res.status(401).json({ error: 'Invalid token', reason: result.reason });
|
||||
return;
|
||||
}
|
||||
if (result.infraError) {
|
||||
// Backend could not reach a verdict (unreachable / malformed). Fail
|
||||
// closed, but do NOT tell the user they are "not whitelisted" — it is
|
||||
@@ -484,12 +517,21 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancellation (Streamable HTTP: closing the response stream cancels the request): the
|
||||
// caller context carries a signal that fires when the client goes away before the
|
||||
// response is written, so the backend work of an abandoned call stops (client.ts apiFetch).
|
||||
const cancellation = new AbortController();
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) cancellation.abort();
|
||||
});
|
||||
|
||||
// Scope the caller context (token + platform role) to this async context
|
||||
// so concurrent sessions cannot overwrite each other on the event loop,
|
||||
// and so tools can route based on role without re-checking the JWT.
|
||||
runWithCaller(
|
||||
{
|
||||
token,
|
||||
signal: cancellation.signal,
|
||||
isGlobalAdmin: result.isGlobalAdmin,
|
||||
isGlobalReader: result.isGlobalReader,
|
||||
delegatedTenantIds: result.delegatedTenantIds,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Auth module for the remote MCP server.
|
||||
*
|
||||
* In remote mode, the Claude Code client handles OAuth and sends a Bearer token
|
||||
* with each MCP request. This module provides helpers for token validation and
|
||||
* extracting user info from the JWT claims.
|
||||
* The MCP client obtains a Bearer token through this server's OAuth proxy (oauth.ts) and sends it
|
||||
* with each MCP request. This module reads the JWT claims; the token itself is validated by the
|
||||
* backend.
|
||||
*
|
||||
* The user's token is passed through to the backend API (access_as_user scope),
|
||||
* so no service principal or separate credentials are needed.
|
||||
* Token model — a DELIBERATE deviation from the MCP authorization spec, recorded as a decision in
|
||||
* the internal decision register: the token's audience is the backend API app (access_as_user),
|
||||
* not this server, and the same token is forwarded to the backend. The spec requires an MCP
|
||||
* server to accept only tokens issued for itself and never to transit them downstream. The
|
||||
* compensations: the backend validates signature, issuer, audience and lifetime on EVERY
|
||||
* forwarded call (this server never treats a token as valid on its own), /api/auth/mcp decides
|
||||
* MCP eligibility per principal, and every tool reaches only its own backend path (followNextLink
|
||||
* basePath pinning). Audience separation would need a second app registration with consent in
|
||||
* every customer tenant plus an on-behalf-of exchange, and would break the app-only
|
||||
* service-principal path — not built until a reviewer requires it.
|
||||
*/
|
||||
|
||||
import { createDecoder } from './jwt-decode.js';
|
||||
@@ -30,16 +38,12 @@ export interface TokenClaims {
|
||||
/** Calling application's client id on a v2.0 token. */
|
||||
azp?: string;
|
||||
/**
|
||||
* Audience. Parsed for observability/diagnostics but intentionally NOT
|
||||
* validated here — the same Bearer token is passed through to the backend
|
||||
* API, which enforces the audience cryptographically alongside the
|
||||
* signature/issuer/lifetime checks. See
|
||||
* `src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs:200`
|
||||
* (`ValidateAudience = true`, `ValidAudiences = { clientId, api://clientId }`).
|
||||
* Duplicating that gate here would only add a second, drift-prone copy of the
|
||||
* accepted-audience list (RFC 8707 resource indicators are honored by the
|
||||
* backend, not the proxy). This is spec-conformant token pass-through: the
|
||||
* user's token is forwarded to the resource it was issued for.
|
||||
* Audience. Parsed for observability/diagnostics but intentionally NOT validated here — the
|
||||
* backend enforces it cryptographically alongside the signature/issuer/lifetime checks
|
||||
* (`AuthenticationMiddleware.cs`: `ValidateAudience = true`, `ValidAudiences = { clientId,
|
||||
* api://clientId }`); duplicating that gate here would only add a second, drift-prone copy of
|
||||
* the accepted-audience list. The audience is the backend API, not this server — see the
|
||||
* module header for why that deviation from the MCP spec is deliberate.
|
||||
*/
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user