mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51aaa2e80a | |||
| 0a6779cd26 | |||
| 1a1c2b413e | |||
| 5e763375b4 | |||
| 9f4f456dc5 | |||
| af2f2c7abe | |||
| 98d87a8579 | |||
| 0709ce2f76 | |||
| 69d876b0a6 | |||
| 224eb668a1 | |||
| a6980a93f6 | |||
| eb57980b53 | |||
| c4c2415de8 | |||
| 9331366025 | |||
| 85a0cb3c31 | |||
| 5efa7d078c | |||
| 35d1445ebb | |||
| 23cd9050ea | |||
| 8615fb458f | |||
| 645f747191 |
@@ -38,6 +38,8 @@
|
||||
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
|
||||
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
|
||||
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
|
||||
- Reuse before you write: search for an existing helper by what it does (its key call, literal or regex), not by a guessed name. Web helpers live in `lib/` (route-only ones in a `lib/` beside the route), backend helpers in `Functions/Helpers/`.
|
||||
- Dead and copied code is gated: the .NET build fails on unused private members and parameters (`.editorconfig`), `npm run knip` covers web and MCP, and `npm run dup` in `tools/quality` fails on new clones. CI only reports, so run them before calling a change done.
|
||||
- English for code comments and everything that carries knowledge.
|
||||
|
||||
Private half of these instructions (process, working files, technical docs, operations): @../internal/CLAUDE.md
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: 500773e336...ce2fb06a44
@@ -0,0 +1,11 @@
|
||||
# Analyzer severities for the .NET build. Directory.Build.props runs the IDE rules in
|
||||
# `dotnet build` (EnforceCodeStyleInBuild), and the warning freeze makes every warning an error.
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
# Unused private members, private members that are written but never read, and unused parameters
|
||||
# of non-public methods. An injected dependency nobody reads is a feature that silently never runs.
|
||||
dotnet_diagnostic.IDE0051.severity = warning
|
||||
dotnet_diagnostic.IDE0052.severity = warning
|
||||
dotnet_diagnostic.IDE0060.severity = warning
|
||||
dotnet_code_quality_unused_parameters = non_public
|
||||
@@ -81,6 +81,23 @@ updates:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Duplicate-check tooling (jscpd for tools/quality/check-duplicates.mjs), installed by the
|
||||
# CI duplicates job from this lockfile. A jscpd update can change the clone fingerprints:
|
||||
# if its PR fails the job, regenerate the baseline (npm run dup:baseline) in that PR.
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/quality
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
quality-tooling:
|
||||
patterns: ['*']
|
||||
quality-tooling-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: CI
|
||||
|
||||
# Tests, typechecks and drift-checks every push to main and every pull request:
|
||||
# the .NET suites, the web and MCP suites, and a guard that the generated rule
|
||||
# files still match rules/guardrails.json.
|
||||
# the .NET suites, the web and MCP suites, a guard that the generated rule
|
||||
# files still match rules/guardrails.json, and the duplicate-code check.
|
||||
#
|
||||
# The deploy workflows build but never test, so this is the only place the suites
|
||||
# actually run. It does not gate anything — main is deliberately unprotected, so a
|
||||
@@ -160,6 +160,12 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
# Unused files, exports and dependencies (knip.json). Its own script on
|
||||
# purpose: chained into `lint`, the `-- --max-warnings 0` above would be
|
||||
# handed to knip and ESLint would lose its cap.
|
||||
- name: Unused code
|
||||
run: npm run knip
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -182,9 +188,19 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# tsconfig.json covers sources, tests and scripts; the build emits via
|
||||
# tsconfig.build.json.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Same rule as the web job: any warning fails — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Unused code
|
||||
run: npm run knip
|
||||
|
||||
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
|
||||
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
|
||||
# node_modules by default, which npm ci recreates — so every CI run
|
||||
@@ -247,8 +263,33 @@ jobs:
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
duplicates:
|
||||
name: Duplicate code
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tools/quality
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/quality/package-lock.json
|
||||
|
||||
- run: npm ci --ignore-scripts
|
||||
|
||||
# jscpd over the C# and TypeScript sources (.jscpd.json). A clone missing from
|
||||
# jscpd-baseline.json fails, and so does a baseline entry whose clone is gone,
|
||||
# so the baseline only shrinks. Locally: npm run dup in tools/quality.
|
||||
- name: Duplicate check
|
||||
run: npm run dup
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -- rules/dist/ \
|
||||
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
|
||||
src/Web/autopilot-monitor-web/lib/generated/guardrails.generated.ts \
|
||||
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
|
||||
if git diff --cached --quiet; then
|
||||
echo "Generated outputs unchanged - nothing to commit"
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
<!-- Run the IDE code-style analyzers in the build too, so the rules .editorconfig raises
|
||||
to warning fail it like any compiler warning. -->
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+1
-1
Submodule internal updated: be4b922266...50f2c079bc
@@ -86,12 +86,12 @@ for (const config of configs) {
|
||||
// ── Guardrails generation ───────────────────────────────────────────────────
|
||||
|
||||
const guardrailsPath = path.join(rulesRoot, 'guardrails.json');
|
||||
// NOTE: consumers import from "@/utils/guardrails.generated" (see guardValidation.ts
|
||||
// NOTE: consumers import from "@/lib/generated/guardrails.generated" (see guardValidation.ts
|
||||
// and SectionGatherRules.tsx). The web re-org (commit a3e8e044) moved this file to
|
||||
// utils/ but left this output path pointing at the now-orphaned lib/ copy, which
|
||||
// silently froze the live allowlist. Keep this in sync with the import path.
|
||||
const guardrailsOutput = path.resolve(
|
||||
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'utils', 'guardrails.generated.ts'
|
||||
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'lib', 'generated', 'guardrails.generated.ts'
|
||||
);
|
||||
|
||||
if (fs.existsSync(guardrailsPath)) {
|
||||
|
||||
+4
-5
@@ -64,7 +64,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
private void Buffer(string? rebootUri, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null, string? description = null)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
eventId: MdmRebootPolicyTracker.EventId_PolicyRebootRequired,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: timeCreatedUtc ?? At,
|
||||
rebootUri: rebootUri,
|
||||
@@ -226,13 +225,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// The coalesced reboot restarts the agent; the post-reboot backfill re-reads the same
|
||||
// records — a FLUSHED record must be skipped by the next instance.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
first.ProcessEvent(recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
first.FlushPending();
|
||||
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
second.ProcessEvent(recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
@@ -245,13 +244,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// Process killed by the very reboot the records announce: nothing was flushed, so the
|
||||
// next instance must re-read and emit the records.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
first.ProcessEvent(recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
// no FlushPending — simulated reboot kill
|
||||
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
second.ProcessEvent(recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
|
||||
+9
-9
@@ -77,7 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
@@ -127,7 +127,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var progress = false;
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
|
||||
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
|
||||
|
||||
ing.Start();
|
||||
@@ -156,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var progress = false;
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
|
||||
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
|
||||
|
||||
ing.Start();
|
||||
@@ -186,7 +186,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
|
||||
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
|
||||
|
||||
ing.Start();
|
||||
@@ -212,7 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
|
||||
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
|
||||
|
||||
ing.Start();
|
||||
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
@@ -259,7 +259,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
@@ -282,7 +282,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = new UserEspKeepAwakeHost(
|
||||
"S1", "T1", ing, clock, NewLogger(),
|
||||
ing, clock, NewLogger(),
|
||||
controller: controller,
|
||||
safetyCapOverride: TimeSpan.FromMilliseconds(75),
|
||||
espTimeoutProvider: () => null);
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
// from a real backend). We exercise the bundle-shape contract here.
|
||||
var rcs = new AutopilotMonitor.Agent.V2.Core.Configuration.RemoteConfigService(
|
||||
auth.BackendApiClient, "tenant-x", logger,
|
||||
auth.EmergencyReporter, auth.DistressReporter, auth.AuthFailureTracker);
|
||||
auth.EmergencyReporter, auth.AuthFailureTracker);
|
||||
var rc = new AgentConfigResponse();
|
||||
var merge = new RemoteConfigMergeResult();
|
||||
|
||||
|
||||
+3
-3
@@ -479,14 +479,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
public HashSet<string> ExistingKeys { get; } =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyList<string> Enumerate(RegistryHive hive, string packagesPath)
|
||||
public IReadOnlyList<string> Enumerate(RegistryHive _, string packagesPath)
|
||||
{
|
||||
return PackagesByPath.TryGetValue(packagesPath, out var ids)
|
||||
? (IReadOnlyList<string>)ids
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
public bool TryRead(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot)
|
||||
public bool TryRead(RegistryHive _, string _1, string packageId, out RealmJoinPackageSnapshot snapshot)
|
||||
{
|
||||
snapshot = new RealmJoinPackageSnapshot(
|
||||
packageId: packageId,
|
||||
@@ -497,7 +497,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool KeyExists(RegistryHive hive, string subPath)
|
||||
public bool KeyExists(RegistryHive _, string subPath)
|
||||
=> ExistingKeys.Contains(subPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _cacheFilePath;
|
||||
private readonly EmergencyReporter _emergencyReporter;
|
||||
private readonly DistressReporter _distressReporter;
|
||||
private readonly AuthFailureTracker _authFailureTracker;
|
||||
|
||||
private AgentConfigResponse _currentConfig;
|
||||
@@ -92,13 +91,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool RecoveredAfterFetchFailure { get; private set; }
|
||||
|
||||
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null, AuthFailureTracker authFailureTracker = null)
|
||||
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, AuthFailureTracker authFailureTracker = null)
|
||||
{
|
||||
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_emergencyReporter = emergencyReporter;
|
||||
_distressReporter = distressReporter;
|
||||
_authFailureTracker = authFailureTracker;
|
||||
|
||||
var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config");
|
||||
|
||||
+4
-4
@@ -990,7 +990,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// for each detection. When HS-NEW-RESULT later fires with the full payload, the UI
|
||||
/// reducer's dataCompleteness scoring keeps the more complete entry.
|
||||
/// </summary>
|
||||
private void HandleHealthScriptDetectionResult(Match match, Dictionary<string, string> parameters)
|
||||
private void HandleHealthScriptDetectionResult(Match match)
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
var compliance = match.Groups["compliance"]?.Value;
|
||||
@@ -1112,7 +1112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
sample,
|
||||
@"\[HS\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
HandleHealthScriptDetectionResult(match, new Dictionary<string, string>());
|
||||
HandleHealthScriptDetectionResult(match);
|
||||
}
|
||||
|
||||
/// <summary>Test seam: seed the per-policy health-script cycle start timestamp as if an
|
||||
@@ -1125,7 +1125,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_healthScriptStartTimes[policyId] = (startedAtUtc, provenance);
|
||||
}
|
||||
|
||||
private void HandleHealthScriptResult(Match match, Dictionary<string, string> parameters)
|
||||
private void HandleHealthScriptResult(Match match)
|
||||
{
|
||||
var json = match.Groups["json"]?.Value;
|
||||
if (string.IsNullOrEmpty(json))
|
||||
@@ -1141,7 +1141,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Test seam that bypasses the regex-match plumbing and feeds a raw <c>[HS] new result</c>
|
||||
/// JSON payload directly into the parser. Production code path runs through
|
||||
/// <see cref="HandleHealthScriptResult(Match, Dictionary{string, string})"/>; this method
|
||||
/// <see cref="HandleHealthScriptResult(Match)"/>; this method
|
||||
/// exists so unit tests can exercise the JSON parser + 1-3 phase emit logic without
|
||||
/// having to write CMTrace log files into a temp directory.
|
||||
/// </summary>
|
||||
|
||||
+2
-2
@@ -882,11 +882,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
break;
|
||||
|
||||
case "healthscriptresult":
|
||||
HandleHealthScriptResult(match, pattern.Parameters);
|
||||
HandleHealthScriptResult(match);
|
||||
break;
|
||||
|
||||
case "healthscriptdetectionresult":
|
||||
HandleHealthScriptDetectionResult(match, pattern.Parameters);
|
||||
HandleHealthScriptDetectionResult(match);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
-2
@@ -237,7 +237,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var rebootUri = ExtractRebootUri(ParseEventData(xml), description);
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
rebootUri: rebootUri,
|
||||
@@ -251,7 +250,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// — after the debounce quiet-period, at backfill completion, or at <see cref="Stop"/>.
|
||||
/// </summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
string rebootUri,
|
||||
|
||||
+2
-2
@@ -1382,7 +1382,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// window (user clicks "Try again" / IME re-sync flips the subcategory back
|
||||
// to inProgress) — firing the stale args would terminate the agent on a
|
||||
// failure Windows no longer reports, and the retry outcome is lost.
|
||||
recovered = IsSettledFailureRecovered(categoryName, args);
|
||||
recovered = IsSettledFailureRecovered(categoryName);
|
||||
if (recovered)
|
||||
{
|
||||
if (args.FailedSubcategory != null
|
||||
@@ -1447,7 +1447,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// and must fire (with the latched args — statusText/HRESULT of the new subcategory
|
||||
/// were never captured). Caller holds <see cref="_stateLock"/>.
|
||||
/// </summary>
|
||||
private bool IsSettledFailureRecovered(string categoryName, EspFailureDetectedEventArgs args)
|
||||
private bool IsSettledFailureRecovered(string categoryName)
|
||||
{
|
||||
if (_lastCategorySucceeded != null
|
||||
&& _lastCategorySucceeded.TryGetValue(categoryName, out var succeeded)
|
||||
|
||||
+2
-2
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (alreadyFired)
|
||||
continue;
|
||||
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes, genAtStart);
|
||||
RunProbe(probeIndex, idleMinutes, genAtStart);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
@@ -253,7 +253,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes, int generation)
|
||||
private void RunProbe(int probeIndex, double idleMinutes, int generation)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
|
||||
|
||||
+3
-3
@@ -1052,7 +1052,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!IsAllowedBlobUploadUrl(blobUploadUrl))
|
||||
{
|
||||
_logger.Warning(
|
||||
$"Blob upload rejected: URL is not an allowed Azure Blob Storage endpoint " +
|
||||
$"Blob upload of {blobName} rejected: URL is not an allowed Azure Blob Storage endpoint " +
|
||||
$"(expected https://*.blob.core.windows.net or sovereign-cloud equivalent). " +
|
||||
$"URL prefix: {BuildSasUrlPrefix(blobUploadUrl)}");
|
||||
return (false, "url_host_rejected");
|
||||
@@ -1086,7 +1086,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Auth errors (401/403) are permanent — SAS token invalid or expired, retrying won't help
|
||||
if (statusCode == 401 || statusCode == 403)
|
||||
{
|
||||
_logger.Warning($"Blob upload auth error (not retryable): {lastErrorCode} - {responseBody}");
|
||||
_logger.Warning($"Blob upload of {blobName}: auth error (not retryable): {lastErrorCode} - {responseBody}");
|
||||
return (false, lastErrorCode);
|
||||
}
|
||||
|
||||
@@ -1106,7 +1106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts");
|
||||
_logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts: {blobName}");
|
||||
return (false, lastErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// On any failure, returns normally so the current version continues.
|
||||
/// </summary>
|
||||
/// <param name="forceUpdate">
|
||||
/// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger
|
||||
/// When true, bypasses the version comparison — used by the runtime-hash-mismatch trigger
|
||||
/// to cover hotfixes that reuse the same version number.
|
||||
/// </param>
|
||||
/// <param name="triggerReason">
|
||||
@@ -517,16 +517,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns true if latest is newer than current.
|
||||
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
|
||||
/// System.Version cannot handle them (e.g. "1.0.386+b7f8d3c..." would fail).
|
||||
/// </summary>
|
||||
private static bool IsNewerVersion(string current, string latest)
|
||||
{
|
||||
return CompareVersions(current, latest) > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns negative when latest is older than current,
|
||||
/// zero when equal, positive when latest is newer than current. Returns
|
||||
|
||||
-5
@@ -827,11 +827,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
sb.Append(value);
|
||||
}
|
||||
|
||||
private EventSeverity ParseSeverity(string severity)
|
||||
{
|
||||
return GatherRuleContext.ParseSeverity(severity);
|
||||
}
|
||||
|
||||
private void StopAllTimers()
|
||||
{
|
||||
foreach (var timer in _intervalTimers.Values)
|
||||
|
||||
+2
-5
@@ -104,7 +104,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
private bool _preinstalledReported; // office_preinstalled_detected already emitted (emit-once guard)
|
||||
private DateTime? _startedAtUtc;
|
||||
private string? _startedTrigger;
|
||||
private OfficeDoSample? _lastDo;
|
||||
private OfficeDoSample? _peakDo; // highest-bytes sample seen — basis for the completed doSummary
|
||||
|
||||
public OfficeInstallDetector(
|
||||
@@ -252,7 +251,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
// _peakDo). The seed is persisted directly (it is the lifecycle's first DO sample).
|
||||
if (_state == DetectorState.Active)
|
||||
{
|
||||
_lastDo = sample;
|
||||
_peakDo = sample;
|
||||
PersistState(OfficeInstallStateData.StateActive);
|
||||
}
|
||||
@@ -261,7 +259,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
// Active: no progress event — the sample only updates the DO summary data. A grown
|
||||
// peak is re-persisted (throttled) so a mid-install agent restart keeps the doSummary.
|
||||
_lastDo = sample;
|
||||
var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded;
|
||||
if (peakGrew) _peakDo = sample;
|
||||
if (peakGrew && _statePersistence != null
|
||||
@@ -380,7 +377,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
// wrapper that re-runs C2R) the watcher's initial scan completes the lifecycle synchronously.
|
||||
// Emitting started first guarantees the correct order (started → completed); otherwise a
|
||||
// pre-installed Office produced completed-before-started (field session a7525e97).
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallStarted, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false);
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallStarted, snap, EventSeverity.Info, InFlightPhase, isTerminal: false);
|
||||
// Persist Active BEFORE ObserveInstallationPath: when Office is already on disk the
|
||||
// host's binary watcher completes the lifecycle synchronously from inside that callback
|
||||
// (and persists Completed) — persisting Active afterwards would overwrite the terminal.
|
||||
@@ -508,7 +505,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
// StreamingFinished does not exist in the C2R registry, so the in-flight phase is reported as a
|
||||
// single "Installing"; the terminal completion uses "Completed".
|
||||
private static string PhaseOf(OfficeC2RSnapshot snap) => "Installing";
|
||||
private const string InFlightPhase = "Installing";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Payload
|
||||
|
||||
+3
-12
@@ -26,7 +26,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _apiBaseUrl;
|
||||
|
||||
private NetworkStateSnapshot _lastKnownState;
|
||||
private Timer _debounceTimer;
|
||||
@@ -54,14 +53,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
string apiBaseUrl = null)
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -225,15 +222,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
if (_disposed || _connectivityCheckClient == null) return;
|
||||
|
||||
// Build endpoint list: static MDM endpoints + dynamic backend URL
|
||||
// Static MDM endpoints only. The backend API stays out on purpose: enrollment does not
|
||||
// need it, so its outage must not raise a connectivity alarm.
|
||||
var endpoints = new List<(string Name, string Url)>(MdmEndpoints);
|
||||
// Optionally add the Autopilot Monitor API endpoint, but we don't want to cause false alarms if it's down or unreachable,
|
||||
// since it's not critical for enrollment success and may be available only after certain network changes (e.g. if it's behind a captive portal).
|
||||
// For now, we can omit it from the critical checks.
|
||||
// if (!string.IsNullOrEmpty(_apiBaseUrl))
|
||||
// {
|
||||
// endpoints.Add(("Autopilot Monitor API", _apiBaseUrl));
|
||||
// }
|
||||
|
||||
// Run all checks in parallel
|
||||
var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray();
|
||||
|
||||
+1
-4
@@ -69,20 +69,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock? _clock;
|
||||
|
||||
public ProvisioningPackageCollector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock? clock = null)
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -377,8 +377,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (analyzers.KeepAwakeDuringUserEsp)
|
||||
{
|
||||
hosts.Add(new UserEspKeepAwakeHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
@@ -648,8 +646,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
tenantId: tenantId,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
apiBaseUrl: _agentConfig.ApiBaseUrl));
|
||||
logger: logger));
|
||||
|
||||
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
|
||||
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private readonly DesktopArrivalDetector _detector;
|
||||
private readonly DesktopArrivalDetectorAdapter _adapter;
|
||||
private readonly AgentLogger _logger;
|
||||
private int _disposed;
|
||||
|
||||
public DesktopArrivalHost(
|
||||
@@ -30,7 +29,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string? tenantId = null,
|
||||
Action<string>? onRealUserOwnerObserved = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_detector = new DesktopArrivalDetector(logger, noCandidateTimeoutMinutes);
|
||||
_adapter = new DesktopArrivalDetectorAdapter(_detector, ingress, clock);
|
||||
|
||||
|
||||
@@ -26,14 +26,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
string? apiBaseUrl)
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new NetworkChangeDetector(sessionId, tenantId, post, logger, apiBaseUrl);
|
||||
_detector = new NetworkChangeDetector(sessionId, tenantId, post, logger);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_collector = new ProvisioningPackageCollector(sessionId, tenantId, post, logger, clock);
|
||||
_collector = new ProvisioningPackageCollector(sessionId, tenantId, post, logger);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +91,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private static readonly string AccountSetupPhaseName = EnrollmentPhase.AccountSetup.ToString();
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly KeepAwakeController _controller;
|
||||
@@ -112,8 +110,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _disposed;
|
||||
|
||||
public UserEspKeepAwakeHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
@@ -125,8 +121,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_sessionId = sessionId ?? string.Empty;
|
||||
_tenantId = tenantId ?? string.Empty;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_controller = controller ?? new KeepAwakeController(logger);
|
||||
|
||||
-2
@@ -28,7 +28,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
private readonly ITelemetrySpool _spool;
|
||||
private readonly IBackendTelemetryUploader _uploader;
|
||||
private readonly IClock _clock;
|
||||
private readonly int _batchSize;
|
||||
private readonly IReadOnlyList<TimeSpan> _retryBackoffs;
|
||||
private readonly SemaphoreSlim _drainGuard = new SemaphoreSlim(1, 1);
|
||||
private bool _disposed;
|
||||
@@ -73,7 +72,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
|
||||
if (batchSize <= 0) throw new ArgumentOutOfRangeException(nameof(batchSize), "BatchSize must be positive.");
|
||||
_batchSize = batchSize;
|
||||
_effectiveBatchSize = batchSize;
|
||||
|
||||
_retryBackoffs = retryBackoffs ?? DefaultRetryBackoffs;
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
var remoteConfigService = new RemoteConfigService(
|
||||
auth.BackendApiClient, agentConfig.TenantId, logger,
|
||||
auth.EmergencyReporter, auth.DistressReporter, auth.AuthFailureTracker);
|
||||
auth.EmergencyReporter, auth.AuthFailureTracker);
|
||||
// retryOnTransientErrors:true is essential for the initial fetch — a Function App
|
||||
// cold-start (typically 30-60 s after a deploy) will time out a single-shot call and
|
||||
// silently strand the agent on built-in defaults for the entire session. Auth
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
return (new AnalyzeRulesFunction(NullLogger<AnalyzeRulesFunction>.Instance, service), stored);
|
||||
return (new AnalyzeRulesFunction(service), stored);
|
||||
}
|
||||
|
||||
private static AnalyzeRule SpoofedPayload() => new()
|
||||
|
||||
@@ -71,7 +71,6 @@ public class AppHomingServiceTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
configuration,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
@@ -69,8 +69,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
@@ -202,8 +201,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
CancellationToken observed = default;
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
|
||||
@@ -49,8 +49,7 @@ public class ConfigBackupRepositoryTests
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
Sut = new TableConfigBackupRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
NullLogger<TableConfigBackupRepository>.Instance);
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance));
|
||||
}
|
||||
|
||||
public void SetupQuery(params TableEntity[] rows)
|
||||
|
||||
@@ -684,6 +684,65 @@ public class ConfigWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Notification channel health (new endpoint, shape pinned) ------------------------
|
||||
|
||||
[Fact]
|
||||
public void NotificationChannelHealthResponse_pins_the_status_shape_and_omits_unset_fields()
|
||||
{
|
||||
var at = new DateTime(2026, 10, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
channels = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
channelId = "c1",
|
||||
status = "Failing",
|
||||
recentAttempts = 5,
|
||||
recentFailures = 2,
|
||||
consecutiveFailures = 2,
|
||||
lastAttemptUtc = at,
|
||||
lastSuccessUtc = at.AddDays(-1),
|
||||
lastFailureUtc = at,
|
||||
failingSinceUtc = at.AddHours(-2),
|
||||
lastStatusCode = 401,
|
||||
lastError = "HTTP 401 Unauthorized",
|
||||
},
|
||||
new
|
||||
{
|
||||
channelId = "c2",
|
||||
status = "Unknown",
|
||||
recentAttempts = 0,
|
||||
recentFailures = 0,
|
||||
consecutiveFailures = 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
new NotificationChannelHealthResponse
|
||||
{
|
||||
Channels =
|
||||
{
|
||||
new NotificationChannelHealthDto
|
||||
{
|
||||
ChannelId = "c1",
|
||||
Status = AutopilotMonitor.Shared.Models.Notifications.NotificationChannelHealthStatus.Failing,
|
||||
RecentAttempts = 5,
|
||||
RecentFailures = 2,
|
||||
ConsecutiveFailures = 2,
|
||||
LastAttemptUtc = at,
|
||||
LastSuccessUtc = at.AddDays(-1),
|
||||
LastFailureUtc = at,
|
||||
FailingSinceUtc = at.AddHours(-2),
|
||||
LastStatusCode = 401,
|
||||
LastError = "HTTP 401 Unauthorized",
|
||||
},
|
||||
new NotificationChannelHealthDto { ChannelId = "c2" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateAdminConfiguration --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -65,8 +65,7 @@ public class CorporateIdentifierValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
@@ -140,8 +140,7 @@ public class CriticalTableRestoreServiceTests
|
||||
["IsEnabled"] = ("Boolean", true),
|
||||
});
|
||||
var sha = ComputeSha(lineBytes);
|
||||
var manifest = MakeManifestForTable(Constants.TableNames.GlobalAdmins,
|
||||
MakeEntry(Constants.TableNames.GlobalAdmins, sha, lineBytes.Length));
|
||||
var manifest = MakeManifestForTable(MakeEntry(Constants.TableNames.GlobalAdmins, sha, lineBytes.Length));
|
||||
|
||||
var fakeStore = new FakeStore(manifest, lineBytes, new ETag("\"0xABC\""));
|
||||
var tables = BuildTableStorageWithLiveRow(liveRow: null);
|
||||
@@ -179,9 +178,9 @@ public class CriticalTableRestoreServiceTests
|
||||
};
|
||||
|
||||
private static CriticalTableBackupManifest MakeManifest(CriticalTableBackupTableEntry entry) =>
|
||||
MakeManifestForTable(Table, entry);
|
||||
MakeManifestForTable(entry);
|
||||
|
||||
private static CriticalTableBackupManifest MakeManifestForTable(string tableName, CriticalTableBackupTableEntry entry) => new()
|
||||
private static CriticalTableBackupManifest MakeManifestForTable(CriticalTableBackupTableEntry entry) => new()
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
BackupId = BackupId,
|
||||
|
||||
@@ -26,6 +26,7 @@ public class CrossTenantFanOutTests
|
||||
{
|
||||
private const string TenantA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
private const string TenantB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
private const string TenantC = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStats_DrainsIndexPerTenant_WithRowKeyBound_AndIsCached()
|
||||
@@ -33,7 +34,7 @@ public class CrossTenantFanOutTests
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 120), IndexRow(TenantB, "s2", SessionStatus.Failed, null));
|
||||
var sessions = MockTableClientReturning();
|
||||
var config = MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB));
|
||||
var storage = BuildStorage(config, sessions, index, apps: new Mock<TableClient>());
|
||||
var storage = BuildStorage(config, sessions, index, apps: new Mock<TableClient>(), counters: Counters(TenantA, TenantB));
|
||||
|
||||
var stats = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null);
|
||||
var again = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null);
|
||||
@@ -56,7 +57,7 @@ public class CrossTenantFanOutTests
|
||||
{
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60));
|
||||
var config = MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB));
|
||||
var storage = BuildStorage(config, MockTableClientReturning(), index, apps: new Mock<TableClient>());
|
||||
var storage = BuildStorage(config, MockTableClientReturning(), index, apps: new Mock<TableClient>(), counters: Counters(TenantA, TenantB));
|
||||
|
||||
await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 30, allowedTenantIds: new[] { TenantB.ToUpperInvariant() });
|
||||
|
||||
@@ -69,7 +70,7 @@ public class CrossTenantFanOutTests
|
||||
{
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60));
|
||||
var sessions = MockTableClientReturning();
|
||||
var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), sessions, index, apps: new Mock<TableClient>());
|
||||
var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), sessions, index, apps: new Mock<TableClient>(), counters: Counters(TenantA, TenantB));
|
||||
|
||||
var start = DateTime.UtcNow.AddDays(-30);
|
||||
var end = DateTime.UtcNow.AddDays(1);
|
||||
@@ -99,7 +100,7 @@ public class CrossTenantFanOutTests
|
||||
public async Task AppSummaries_CrossTenant_OnePartitionQueryPerTenant_SharedAcrossConcurrentCallers()
|
||||
{
|
||||
var apps = MockTableClientReturning(AppRow(TenantA, "s1", "Teams"), AppRow(TenantB, "s2", "Office"));
|
||||
var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), MockTableClientReturning(), MockTableClientReturning(), apps);
|
||||
var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), MockTableClientReturning(), MockTableClientReturning(), apps, counters: Counters(TenantA, TenantB));
|
||||
|
||||
var since = DateTime.UtcNow.AddDays(-30);
|
||||
// The Installs tab fires these two at once; the usage compute adds the refs projection.
|
||||
@@ -132,6 +133,114 @@ public class CrossTenantFanOutTests
|
||||
Assert.All(apps.Selects, s => Assert.Equal(TableStorageService.AppMetricsProjection, s));
|
||||
}
|
||||
|
||||
// ── enrolled-tenant snapshot: the fan-out width is the set of tenants with an enrollment on record ──
|
||||
|
||||
[Fact]
|
||||
public async Task SessionList_FansOutOnlyOverEnrolledTenants_AndScansCountersOncePerTtl()
|
||||
{
|
||||
// Config knows three tenants: A enrolled, B's counter row stands at 0, C has no counter row at all.
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60));
|
||||
var counters = MockTableClientReturning(CounterRow(TenantA, 5), CounterRow(TenantB, 0));
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB), ConfigRow(TenantC)),
|
||||
MockTableClientReturning(), index, apps: new Mock<TableClient>(), counters: counters);
|
||||
|
||||
var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null);
|
||||
await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null);
|
||||
|
||||
Assert.Single(page.Items);
|
||||
// Two pages, one partition query each — only the enrolled tenant is ever asked.
|
||||
Assert.Equal(2, index.Filters.Count);
|
||||
Assert.All(index.Filters, f => Assert.StartsWith($"PartitionKey eq '{TenantA}'", f));
|
||||
// The marker is one projected property-filter read per TTL, shared by both pages.
|
||||
Assert.Equal("RowKey eq 'current'", Assert.Single(counters.Filters));
|
||||
Assert.Equal(new[] { "PartitionKey", "TotalEnrollments" }, Assert.Single(counters.Selects));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionList_CounterScanFailure_FailsOpenToEveryConfiguredTenant()
|
||||
{
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60));
|
||||
var counters = new RecordingTableClient();
|
||||
counters.Setup(c => c.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new RequestFailedException(503, "PlatformStats unavailable"));
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)),
|
||||
MockTableClientReturning(), index, apps: new Mock<TableClient>(), counters: counters);
|
||||
|
||||
var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null);
|
||||
|
||||
// A failed marker read must never narrow the fan-out: every configured tenant is queried.
|
||||
Assert.Single(page.Items);
|
||||
Assert.Equal(2, index.Filters.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionList_CounterRowOfAnUnconfiguredTenant_IsNeverQueried()
|
||||
{
|
||||
// An offboarded tenant may keep its counter row and even index rows; without a config row it is out.
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60), IndexRow(TenantB, "s2", SessionStatus.Failed, null));
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA)),
|
||||
MockTableClientReturning(), index, apps: new Mock<TableClient>(), counters: Counters(TenantA, TenantB));
|
||||
|
||||
var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null);
|
||||
|
||||
Assert.Single(page.Items);
|
||||
Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionList_ConfiguredButNobodyEnrolled_IsAnEmptyPage_NeverTheLegacyScan()
|
||||
{
|
||||
// The primary-table scan is the EMPTY-CONFIG safety net only; "configured, nobody enrolled" is simply empty.
|
||||
var sessions = MockTableClientReturning(new TableEntity(TenantA, "leak"));
|
||||
var index = MockTableClientReturning();
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)),
|
||||
sessions, index, apps: new Mock<TableClient>(), counters: MockTableClientReturning());
|
||||
|
||||
var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null);
|
||||
var stats = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null);
|
||||
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
Assert.Equal(0, stats.TotalLastNDays);
|
||||
AssertNeverQueried(sessions);
|
||||
AssertNeverQueried(index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStats_BoundedCaller_IsCutToEnrolledTenants_AfterTheBound()
|
||||
{
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60), IndexRow(TenantB, "s2", SessionStatus.Succeeded, 60));
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)),
|
||||
MockTableClientReturning(), index, apps: new Mock<TableClient>(), counters: Counters(TenantA));
|
||||
|
||||
// The delegated caller manages both tenants; only A ever enrolled.
|
||||
await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 30, allowedTenantIds: new[] { TenantA, TenantB });
|
||||
|
||||
Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeoWindowAndAppSummaries_CrossTenant_SkipTenantsWithoutEnrollment()
|
||||
{
|
||||
var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60));
|
||||
var apps = MockTableClientReturning(AppRow(TenantA, "s1", "Teams"));
|
||||
var storage = BuildStorage(
|
||||
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)),
|
||||
MockTableClientReturning(), index, apps, counters: Counters(TenantA));
|
||||
|
||||
await storage.GetGeoWindowSessionsAsync(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow.AddDays(1), tenantId: null);
|
||||
await storage.GetAppMetricsSummariesAsync(DateTime.UtcNow.AddDays(-30), tenantId: null);
|
||||
|
||||
Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters));
|
||||
Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(apps.Filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeoProjection_IsFullyMirroredOnSessionsIndex()
|
||||
{
|
||||
@@ -161,16 +270,26 @@ public class CrossTenantFanOutTests
|
||||
private static TableEntity AppRow(string tenantId, string sessionId, string appName)
|
||||
=> new(tenantId, $"{sessionId}_{appName}") { ["SessionId"] = sessionId, ["AppName"] = appName, ["StartedAt"] = DateTime.UtcNow.AddHours(-2) };
|
||||
|
||||
private static TableStorageService BuildStorage(RecordingTableClient config, RecordingTableClient sessions, RecordingTableClient index, Mock<TableClient> apps)
|
||||
/// <param name="counters">PlatformStats counter rows — the enrolled-tenant marker; null = nobody has enrolled.</param>
|
||||
private static TableStorageService BuildStorage(RecordingTableClient config, RecordingTableClient sessions, RecordingTableClient index, Mock<TableClient> apps, RecordingTableClient? counters = null)
|
||||
{
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.TenantConfiguration)).Returns(config.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(index.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.AppInstallSummaries)).Returns(apps.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns((counters ?? MockTableClientReturning()).Object);
|
||||
return new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>A per-tenant counter row (PlatformStats, RowKey "current") — the enrolled-tenant marker.</summary>
|
||||
private static TableEntity CounterRow(string tenantId, long totalEnrollments)
|
||||
=> new(tenantId, "current") { ["TotalEnrollments"] = totalEnrollments };
|
||||
|
||||
/// <summary>Counter rows marking every given tenant as enrolled.</summary>
|
||||
private static RecordingTableClient Counters(params string[] tenantIds)
|
||||
=> MockTableClientReturning(tenantIds.Select(t => CounterRow(t, 1)).ToArray());
|
||||
|
||||
/// <summary>Mock TableClient that records the filter + select of every string-filter QueryAsync call.</summary>
|
||||
private sealed class RecordingTableClient : Mock<TableClient>
|
||||
{
|
||||
|
||||
@@ -114,9 +114,16 @@ public class DelegatedBoundedAggregateTests
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.TenantConfiguration)).Returns(config.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(index.Object);
|
||||
// Every tenant of these tests counts as enrolled: the bounded guard under test must not lean on the
|
||||
// enrolled-tenant snapshot narrowing the set (CrossTenantFanOutTests covers that snapshot).
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(
|
||||
MockTableClientReturning(CounterRow(ConfigTenant), CounterRow(ManagedTenant), CounterRow(UnmanagedTenant)).Object);
|
||||
return new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>A per-tenant PlatformStats counter row — the enrolled-tenant marker of the cross-tenant fan-outs.</summary>
|
||||
private static TableEntity CounterRow(string tenantId) => new(tenantId, "current") { ["TotalEnrollments"] = 1L };
|
||||
|
||||
/// <summary>Mocks both QueryAsync overloads (string + expression filter) to return the given rows.</summary>
|
||||
private static Mock<TableClient> MockTableClientReturning(params TableEntity[] rows)
|
||||
{
|
||||
|
||||
@@ -568,7 +568,7 @@ public class DeletionManifestBuilderTests
|
||||
// ---------------------------------------------------------------- Test-fixture plumbing ----
|
||||
|
||||
private static DeletionManifestBuilder NewBuilder(ISessionDeletionInventoryReader reader)
|
||||
=> new DeletionManifestBuilder(reader, NullLogger<DeletionManifestBuilder>.Instance);
|
||||
=> new DeletionManifestBuilder(reader);
|
||||
|
||||
private static ISessionDeletionInventoryReader NewReader(Action<ReaderSeed> seedAction)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class DiagnosticsBlobCascadeDeleterTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -28,12 +28,6 @@ public class DistressRateLimitServiceTests
|
||||
return new DistressRateLimitService(cache, logger);
|
||||
}
|
||||
|
||||
private static DistressRateLimitService CreateService(IMemoryCache cache)
|
||||
{
|
||||
var logger = NullLogger<DistressRateLimitService>.Instance;
|
||||
return new DistressRateLimitService(cache, logger);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Layer 1: Per-IP Rate Limiting (5 requests / 15 minutes)
|
||||
// =========================================================================
|
||||
|
||||
@@ -22,10 +22,30 @@ public class EmailChannelProviderTests
|
||||
private static readonly NotificationScope Platform = NotificationScope.Platform;
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
{
|
||||
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<IEmailChannelSender> EmailMock()
|
||||
{
|
||||
var mock = new Mock<IEmailChannelSender>();
|
||||
mock.Setup(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
|
||||
@@ -48,30 +68,30 @@ public class EmailChannelProviderTests
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
|
||||
|
||||
email.Verify(e => e.SendOpsAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
email.Verify(e => e.SendAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result()
|
||||
{
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var email = EmailMock();
|
||||
email.Setup(e => e.SendAlertAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
|
||||
email.Verify(e => e.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 1 recipient(s).", result.Message);
|
||||
}
|
||||
@@ -82,7 +102,7 @@ public class EmailChannelProviderTests
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not registered", result.Message);
|
||||
@@ -200,7 +220,7 @@ public class EmailChannelProviderTests
|
||||
var handler = new StubHandler();
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
|
||||
var result = await service.SendAlertWithResultAsync("ops@example.invalid; second@example.invalid", Alert());
|
||||
var result = await service.SendAlertAsync("ops@example.invalid; second@example.invalid", Alert());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 2 recipient(s). Provider status: sent, id abc,abc.", result.Message);
|
||||
@@ -219,17 +239,16 @@ public class EmailChannelProviderTests
|
||||
{
|
||||
var handler = new StubHandler { Responder = _ => StubHandler.Json(HttpStatusCode.OK, "[{\"email\":\"ops@example.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\"}]") };
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
var rejected = await service.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
var rejected = await service.SendAlertAsync("ops@example.invalid", Alert());
|
||||
Assert.False(rejected.Success);
|
||||
Assert.Contains("accepted 0 of 1", rejected.Message);
|
||||
Assert.Contains("Provider status: rejected", rejected.Message);
|
||||
|
||||
var (unconfigured, _) = Sender(new StubHandler(), apiKey: "");
|
||||
var skipped = await unconfigured.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
var skipped = await unconfigured.SendAlertAsync("ops@example.invalid", Alert());
|
||||
Assert.False(skipped.Success);
|
||||
Assert.Contains("not configured", skipped.Message);
|
||||
|
||||
await unconfigured.SendOpsAlertAsync("ops@example.invalid", Alert()); // never throws
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -43,7 +43,7 @@ public class EventDataNormalizationTests
|
||||
[Fact]
|
||||
public void ParseEvent_normalizes_nested_objects_to_native_types()
|
||||
{
|
||||
var evt = TelemetryPayloadParser.ParseEvent(EventDto(HardwareSpecPayload), TenantId, SessionId);
|
||||
var evt = TelemetryPayloadParser.ParseEvent(EventDto(HardwareSpecPayload));
|
||||
|
||||
Assert.NotNull(evt);
|
||||
// Nested array must be a native List<object> of Dictionary<string,object>, NOT a JArray/JObject.
|
||||
@@ -58,7 +58,7 @@ public class EventDataNormalizationTests
|
||||
// The actual corruption manifests when the downstream DeviceSnapshot writer re-serializes
|
||||
// Data with System.Text.Json. Before the fix this produced "disks":[[[[]],...]]; now the
|
||||
// real fields must survive a round-trip.
|
||||
var evt = TelemetryPayloadParser.ParseEvent(EventDto(HardwareSpecPayload), TenantId, SessionId);
|
||||
var evt = TelemetryPayloadParser.ParseEvent(EventDto(HardwareSpecPayload));
|
||||
Assert.NotNull(evt);
|
||||
|
||||
var json = Stj.Serialize(evt!.Data);
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ public class GatherRulesFunctionAuthorEndpointTests
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
return (new GatherRulesFunction(NullLogger<GatherRulesFunction>.Instance, service), stored);
|
||||
return (new GatherRulesFunction(service), stored);
|
||||
}
|
||||
|
||||
private static GatherRule SpoofedPayload() => new()
|
||||
|
||||
@@ -32,8 +32,7 @@ public class GraphAuthFailureTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
return new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
}
|
||||
|
||||
|
||||
-4
@@ -338,7 +338,6 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
cache,
|
||||
tokenConfig,
|
||||
new EntraAppRegistry(tokenConfig, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
@@ -497,7 +496,6 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
@@ -530,7 +528,6 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
@@ -569,7 +566,6 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
cache,
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
|
||||
@@ -48,7 +48,6 @@ public class GraphTokenServiceTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
@@ -237,7 +236,6 @@ public class GraphTokenServiceTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
registry,
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
|
||||
@@ -17,9 +17,6 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class IngestTelemetryMalformedTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
// ============================================================ Deeply-nested JSON (JSON-bomb)
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -79,8 +79,7 @@ public class IntuneEnrollmentValidationTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokens = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Channel delivery health: the status rule and the fold of each send outcome
|
||||
/// (<see cref="NotificationChannelHealthEvaluator"/>), the recorder's compare-and-swap write and
|
||||
/// its once-per-episode bell, the dispatcher's recording of every non-Push send, the read
|
||||
/// endpoint's projection onto the stored channels, and the table mapping.
|
||||
/// </summary>
|
||||
public class NotificationChannelHealthTests
|
||||
{
|
||||
private const string TenantKey = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTime T0 = new(2026, 10, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantKey);
|
||||
|
||||
private static NotificationChannel Webhook(string id = "c1", string url = "https://hooks.example.invalid/a", string name = "Service Desk")
|
||||
=> new() { Id = id, Name = name, ProviderType = (int)WebhookProviderType.GenericJson, Url = url, Enabled = true };
|
||||
|
||||
private static NotificationSendResult Ok() => new() { Success = true, StatusCode = 202, Message = "Test notification sent successfully." };
|
||||
|
||||
private static NotificationSendResult Fail(int? status = 401, string message = "Webhook returned HTTP 401: {\"error\":\"secret body\"}")
|
||||
=> new() { Success = false, StatusCode = status, Message = message };
|
||||
|
||||
private static ChannelHealthFold Fold(NotificationChannelHealth? stored, NotificationSendResult result,
|
||||
ChannelSendKind kind = ChannelSendKind.Delivery, DateTime? at = null, NotificationChannel? channel = null)
|
||||
{
|
||||
channel ??= Webhook();
|
||||
return NotificationChannelHealthEvaluator.Apply(stored, TenantKey, channel.Id,
|
||||
NotificationChannelHealthEvaluator.Fingerprint(channel), result, kind, at ?? T0);
|
||||
}
|
||||
|
||||
// ── Status rule and fold ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void A_delivered_send_on_a_new_channel_is_ok()
|
||||
{
|
||||
var fold = Fold(null, Ok());
|
||||
|
||||
Assert.Equal("1", fold.Next.Recent);
|
||||
Assert.Equal(T0, fold.Next.LastSuccessUtc);
|
||||
Assert.Equal(NotificationChannelHealthStatus.Ok, NotificationChannelHealthEvaluator.Evaluate(fold.Next, T0));
|
||||
Assert.Equal(ChannelHealthTransition.None, fold.Transition);
|
||||
Assert.False(fold.RaiseBell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_failure_degrades_with_the_status_line_and_never_the_body()
|
||||
{
|
||||
var fold = Fold(Fold(null, Ok()).Next, Fail());
|
||||
|
||||
Assert.Equal(1, fold.Next.ConsecutiveFailures);
|
||||
Assert.Equal("HTTP 401 Unauthorized", fold.Next.LastError);
|
||||
Assert.Equal(401, fold.Next.LastStatusCode);
|
||||
Assert.Equal(T0, fold.Next.FailingSinceUtc);
|
||||
Assert.Equal(NotificationChannelHealthStatus.Degraded, NotificationChannelHealthEvaluator.Evaluate(fold.Next, T0));
|
||||
Assert.False(fold.RaiseBell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_failed_deliveries_in_a_row_are_failing_and_ring_the_bell_once_per_episode()
|
||||
{
|
||||
var first = Fold(null, Fail(), at: T0);
|
||||
var second = Fold(first.Next, Fail(), at: T0.AddHours(1));
|
||||
var third = Fold(second.Next, Fail(), at: T0.AddHours(2));
|
||||
|
||||
Assert.Equal(NotificationChannelHealthStatus.Failing, NotificationChannelHealthEvaluator.Evaluate(second.Next, T0.AddHours(1)));
|
||||
Assert.Equal(ChannelHealthTransition.BecameFailing, second.Transition);
|
||||
Assert.True(second.RaiseBell);
|
||||
Assert.Equal(T0.AddHours(1), second.Next.FailingNotifiedUtc);
|
||||
Assert.Equal(T0, second.Next.FailingSinceUtc);
|
||||
|
||||
Assert.Equal(ChannelHealthTransition.None, third.Transition);
|
||||
Assert.False(third.RaiseBell);
|
||||
Assert.Equal(3, third.Next.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failing_test_never_rings_but_the_next_failed_delivery_does()
|
||||
{
|
||||
var first = Fold(null, Fail(), ChannelSendKind.Test);
|
||||
var second = Fold(first.Next, Fail(), ChannelSendKind.Test);
|
||||
Assert.Equal(ChannelHealthTransition.BecameFailing, second.Transition);
|
||||
Assert.False(second.RaiseBell);
|
||||
Assert.Null(second.Next.FailingNotifiedUtc);
|
||||
|
||||
var delivery = Fold(second.Next, Fail());
|
||||
Assert.True(delivery.RaiseBell);
|
||||
Assert.Equal(ChannelHealthTransition.None, delivery.Transition);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChannelSendKind.Delivery)]
|
||||
[InlineData(ChannelSendKind.Test)]
|
||||
public void A_success_ends_the_episode_and_the_next_one_rings_again(ChannelSendKind kind)
|
||||
{
|
||||
var failing = Fold(Fold(null, Fail()).Next, Fail());
|
||||
var recovered = Fold(failing.Next, Ok(), kind, T0.AddHours(3));
|
||||
|
||||
Assert.Equal(ChannelHealthTransition.Recovered, recovered.Transition);
|
||||
Assert.Equal(0, recovered.Next.ConsecutiveFailures);
|
||||
Assert.Null(recovered.Next.FailingSinceUtc);
|
||||
Assert.Null(recovered.Next.FailingNotifiedUtc);
|
||||
Assert.Equal("HTTP 401 Unauthorized", recovered.Next.LastError); // the last failure stays readable
|
||||
|
||||
var again = Fold(Fold(recovered.Next, Fail()).Next, Fail());
|
||||
Assert.True(again.RaiseBell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_failure_keeps_a_delivering_channel_degraded_for_seven_days()
|
||||
{
|
||||
var degraded = Fold(Fold(null, Fail(), at: T0).Next, Ok(), at: T0.AddMinutes(5)).Next;
|
||||
|
||||
Assert.Equal(NotificationChannelHealthStatus.Degraded, NotificationChannelHealthEvaluator.Evaluate(degraded, T0.AddDays(6)));
|
||||
Assert.Equal(NotificationChannelHealthStatus.Ok, NotificationChannelHealthEvaluator.Evaluate(degraded, T0.AddDays(7)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_pushed_out_of_the_kept_outcomes_no_longer_degrades()
|
||||
{
|
||||
var row = Fold(null, Fail(), at: T0).Next;
|
||||
for (var i = 0; i < NotificationChannelHealth.RecentCapacity - 1; i++)
|
||||
row = Fold(row, Ok(), at: T0.AddMinutes(i + 1)).Next;
|
||||
Assert.Equal(NotificationChannelHealthStatus.Degraded, NotificationChannelHealthEvaluator.Evaluate(row, T0.AddHours(1)));
|
||||
|
||||
row = Fold(row, Ok(), at: T0.AddMinutes(30)).Next;
|
||||
Assert.Equal(NotificationChannelHealthStatus.Ok, NotificationChannelHealthEvaluator.Evaluate(row, T0.AddHours(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recent_keeps_the_last_twenty_outcomes()
|
||||
{
|
||||
var row = Fold(null, Fail()).Next;
|
||||
for (var i = 0; i < NotificationChannelHealth.RecentCapacity; i++)
|
||||
row = Fold(row, Ok()).Next;
|
||||
|
||||
Assert.Equal(new string('1', NotificationChannelHealth.RecentCapacity), row.Recent);
|
||||
var dto = NotificationChannelHealthEvaluator.ToDto("c1", row, T0);
|
||||
Assert.Equal(20, dto.RecentAttempts);
|
||||
Assert.Equal(0, dto.RecentFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_destination_starts_fresh_and_an_old_row_reads_as_unknown()
|
||||
{
|
||||
var failing = Fold(Fold(null, Fail()).Next, Fail()).Next;
|
||||
var moved = Webhook(url: "https://hooks.example.invalid/b");
|
||||
var movedFingerprint = NotificationChannelHealthEvaluator.Fingerprint(moved);
|
||||
|
||||
Assert.Null(NotificationChannelHealthEvaluator.Current(failing, movedFingerprint));
|
||||
Assert.Equal(NotificationChannelHealthStatus.Unknown,
|
||||
NotificationChannelHealthEvaluator.Evaluate(NotificationChannelHealthEvaluator.Current(failing, movedFingerprint), T0));
|
||||
|
||||
var fresh = Fold(failing, Ok(), channel: moved);
|
||||
Assert.Equal("1", fresh.Next.Recent);
|
||||
Assert.Equal(movedFingerprint, fresh.Next.Fingerprint);
|
||||
Assert.Equal(ChannelHealthTransition.None, fresh.Transition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_fingerprint_follows_every_field_that_decides_the_destination()
|
||||
{
|
||||
var baseline = NotificationChannelHealthEvaluator.Fingerprint(Webhook());
|
||||
|
||||
Assert.Equal(baseline, NotificationChannelHealthEvaluator.Fingerprint(Webhook(name: "Renamed")));
|
||||
Assert.NotEqual(baseline, NotificationChannelHealthEvaluator.Fingerprint(Webhook(url: "https://hooks.example.invalid/b")));
|
||||
var headers = Webhook(); headers.CustomHeadersJson = "{\"Authorization\":\"Bearer x\"}";
|
||||
Assert.NotEqual(baseline, NotificationChannelHealthEvaluator.Fingerprint(headers));
|
||||
var signed = Webhook(); signed.SigningSecret = "s3cret";
|
||||
Assert.NotEqual(baseline, NotificationChannelHealthEvaluator.Fingerprint(signed));
|
||||
var slack = Webhook(); slack.ProviderType = (int)WebhookProviderType.Slack;
|
||||
Assert.NotEqual(baseline, NotificationChannelHealthEvaluator.Fingerprint(slack));
|
||||
Assert.Equal(16, baseline.Length);
|
||||
Assert.DoesNotContain("hooks", baseline);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(502, "Webhook returned HTTP 502: <html>body</html>", "HTTP 502 Bad Gateway")]
|
||||
[InlineData(404, "", "HTTP 404 Not Found")]
|
||||
[InlineData(599, "", "HTTP 599")]
|
||||
[InlineData(null, "Connection error: No such host is known.", "Connection error: No such host is known.")]
|
||||
[InlineData(null, "", "Delivery failed.")]
|
||||
public void DescribeFailure_uses_the_status_line_or_the_transport_message(int? status, string message, string expected)
|
||||
=> Assert.Equal(expected, NotificationChannelHealthEvaluator.DescribeFailure(new NotificationSendResult { StatusCode = status, Message = message }));
|
||||
|
||||
[Fact]
|
||||
public void DescribeFailure_caps_a_long_transport_message()
|
||||
{
|
||||
var described = NotificationChannelHealthEvaluator.DescribeFailure(new NotificationSendResult { Message = new string('x', 500) });
|
||||
Assert.Equal(NotificationChannelHealthEvaluator.MaxErrorLength, described.Length);
|
||||
Assert.EndsWith("…", described);
|
||||
}
|
||||
|
||||
// ── Recorder ──────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
public DateTime Now { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Now);
|
||||
}
|
||||
|
||||
/// <summary>In-memory table with real ETag semantics; <see cref="BeforeWrite"/> lets a test slip in a competing writer.</summary>
|
||||
private sealed class FakeRepository : INotificationChannelHealthRepository
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<(string, string), (NotificationChannelHealth Row, int Version)> _rows = new();
|
||||
|
||||
public Action<FakeRepository>? BeforeWrite { get; set; }
|
||||
public bool Throw { get; set; }
|
||||
public int Writes { get; private set; }
|
||||
|
||||
public NotificationChannelHealth? Get(string scope, string id)
|
||||
{
|
||||
lock (_gate) return _rows.TryGetValue((scope, id), out var e) ? e.Row : null;
|
||||
}
|
||||
|
||||
public Task<(NotificationChannelHealth? Row, string? ETag)> GetWithETagAsync(string scopeKey, string channelId)
|
||||
{
|
||||
if (Throw) throw new InvalidOperationException("storage down");
|
||||
lock (_gate)
|
||||
{
|
||||
return Task.FromResult<(NotificationChannelHealth?, string?)>(
|
||||
_rows.TryGetValue((scopeKey, channelId), out var e) ? (e.Row, e.Version.ToString()) : (null, null));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> TryUpsertAsync(NotificationChannelHealth row, string? ifMatchETag)
|
||||
{
|
||||
var hook = BeforeWrite;
|
||||
BeforeWrite = null;
|
||||
hook?.Invoke(this);
|
||||
lock (_gate)
|
||||
{
|
||||
var key = (row.ScopeKey, row.ChannelId);
|
||||
var exists = _rows.TryGetValue(key, out var current);
|
||||
if (ifMatchETag == null ? exists : !exists || current.Version.ToString() != ifMatchETag)
|
||||
return Task.FromResult(false);
|
||||
_rows[key] = (row, exists ? current.Version + 1 : 1);
|
||||
Writes++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<NotificationChannelHealth>> ListAsync(string scopeKey)
|
||||
{
|
||||
lock (_gate) return Task.FromResult(_rows.Where(r => r.Key.Item1 == scopeKey).Select(r => r.Value.Row).ToList());
|
||||
}
|
||||
|
||||
public Task<List<(NotificationChannelHealth Row, string ETag)>> ListAllAsync()
|
||||
{
|
||||
if (Throw) throw new InvalidOperationException("storage down");
|
||||
lock (_gate) return Task.FromResult(_rows.Values.Select(v => (v.Row, v.Version.ToString())).ToList());
|
||||
}
|
||||
|
||||
public Task<bool> TryDeleteAsync(NotificationChannelHealth row, string ifMatchETag)
|
||||
{
|
||||
var hook = BeforeWrite;
|
||||
BeforeWrite = null;
|
||||
hook?.Invoke(this);
|
||||
lock (_gate)
|
||||
{
|
||||
var key = (row.ScopeKey, row.ChannelId);
|
||||
if (!_rows.TryGetValue(key, out var current) || current.Version.ToString() != ifMatchETag)
|
||||
return Task.FromResult(false);
|
||||
_rows.Remove(key);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Seeds a row as if its channel last sent at <paramref name="lastAttempt"/>.</summary>
|
||||
public void Seed(string scope, string channelId, DateTime lastAttempt)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var version = _rows.TryGetValue((scope, channelId), out var existing) ? existing.Version + 1 : 1;
|
||||
_rows[(scope, channelId)] = (new NotificationChannelHealth
|
||||
{
|
||||
ScopeKey = scope, ChannelId = channelId, Fingerprint = "f", Recent = "1", LastAttemptUtc = lastAttempt, LastSuccessUtc = lastAttempt,
|
||||
}, version);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Has(string scope, string channelId)
|
||||
{
|
||||
lock (_gate) return _rows.ContainsKey((scope, channelId));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public FakeRepository Repository { get; } = new();
|
||||
public Mock<TenantNotificationService> TenantBell { get; } = new(Mock.Of<ITenantNotificationRepository>(), new FakeSignalRNotificationService(), NullLogger<TenantNotificationService>.Instance);
|
||||
public Mock<GlobalNotificationService> GlobalBell { get; } = new(Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(), NullLogger<GlobalNotificationService>.Instance);
|
||||
public Clock Clock { get; } = new();
|
||||
|
||||
public NotificationChannelHealthRecorder Recorder()
|
||||
=> new(Repository, TenantBell.Object, GlobalBell.Object, NullLogger<NotificationChannelHealthRecorder>.Instance, telemetry: null, time: Clock);
|
||||
|
||||
public void VerifyTenantBells(int times)
|
||||
=> TenantBell.Verify(b => b.CreateNotificationAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Exactly(times));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recorder_rings_the_tenant_bell_once_for_a_failing_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
var recorder = h.Recorder();
|
||||
var channel = Webhook();
|
||||
|
||||
await recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery);
|
||||
h.VerifyTenantBells(0);
|
||||
await recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery);
|
||||
await recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
h.VerifyTenantBells(1);
|
||||
h.TenantBell.Verify(b => b.CreateNotificationAsync(
|
||||
TenantKey,
|
||||
NotificationChannelHealthRecorder.TenantBellType,
|
||||
"Notification channel failing",
|
||||
"\"Service Desk\" could not deliver the last 2 notifications (HTTP 401 Unauthorized). Check the destination, then send a test.",
|
||||
NotificationChannelHealthRecorder.TenantSettingsHref), Times.Once);
|
||||
h.GlobalBell.Verify(b => b.CreateNotificationAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Equal(3, h.Repository.Get(TenantKey, "c1")!.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_writer_that_loses_the_race_to_the_bell_stamp_raises_no_second_bell()
|
||||
{
|
||||
var h = new Harness();
|
||||
var recorder = h.Recorder();
|
||||
var channel = Webhook();
|
||||
await recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
// A concurrent send stamps the episode between this writer's read and its write.
|
||||
h.Repository.BeforeWrite = repo =>
|
||||
{
|
||||
var (stored, etag) = repo.GetWithETagAsync(TenantKey, "c1").Result;
|
||||
var competitor = NotificationChannelHealthEvaluator.Apply(stored, TenantKey, "c1",
|
||||
NotificationChannelHealthEvaluator.Fingerprint(channel), Fail(), ChannelSendKind.Delivery, T0);
|
||||
Assert.True(competitor.RaiseBell);
|
||||
Assert.True(repo.TryUpsertAsync(competitor.Next, etag).Result);
|
||||
};
|
||||
await recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
h.VerifyTenantBells(0); // the competitor owned the bell; this writer re-read and saw the stamp
|
||||
var row = h.Repository.Get(TenantKey, "c1")!;
|
||||
Assert.Equal(3, row.ConsecutiveFailures);
|
||||
Assert.NotNull(row.FailingNotifiedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_failures_raise_exactly_one_bell()
|
||||
{
|
||||
var h = new Harness();
|
||||
var recorder = h.Recorder();
|
||||
var channel = Webhook();
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => Task.Run(() => recorder.RecordAsync(Tenant, channel, Fail(), ChannelSendKind.Delivery))));
|
||||
|
||||
h.VerifyTenantBells(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_ops_channel_rings_the_global_bell()
|
||||
{
|
||||
var h = new Harness();
|
||||
var recorder = h.Recorder();
|
||||
var channel = Webhook(id: "ops");
|
||||
|
||||
await recorder.RecordAsync(NotificationScope.Platform, channel, Fail(), ChannelSendKind.Delivery);
|
||||
await recorder.RecordAsync(NotificationScope.Platform, channel, Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
h.GlobalBell.Verify(b => b.CreateNotificationAsync(NotificationChannelHealthRecorder.OpsBellType,
|
||||
It.IsAny<string>(), It.IsAny<string>(), NotificationChannelHealthRecorder.OpsSettingsHref), Times.Once);
|
||||
h.VerifyTenantBells(0);
|
||||
Assert.NotNull(h.Repository.Get("platform", "ops"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tests_never_ring_and_push_channels_are_not_recorded()
|
||||
{
|
||||
var h = new Harness();
|
||||
var recorder = h.Recorder();
|
||||
|
||||
await recorder.RecordAsync(Tenant, Webhook(), Fail(), ChannelSendKind.Test);
|
||||
await recorder.RecordAsync(Tenant, Webhook(), Fail(), ChannelSendKind.Test);
|
||||
var push = new NotificationChannel { Id = "push", Name = "Phones", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
|
||||
await recorder.RecordAsync(Tenant, push, Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
h.VerifyTenantBells(0);
|
||||
Assert.Equal(2, h.Repository.Get(TenantKey, "c1")!.ConsecutiveFailures);
|
||||
Assert.Null(h.Repository.Get(TenantKey, "push"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_never_reaches_the_caller()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Repository.Throw = true;
|
||||
|
||||
await h.Recorder().RecordAsync(Tenant, Webhook(), Fail(), ChannelSendKind.Delivery);
|
||||
|
||||
h.VerifyTenantBells(0);
|
||||
}
|
||||
|
||||
// ── Dispatcher ────────────────────────────────────────────────────────
|
||||
|
||||
private static NotificationChannelDispatcher Dispatcher(
|
||||
Mock<INotificationChannelHealthRecorder> recorder, Func<NotificationSendResult>? webhookResult = null, Mock<IPushChannelSender>? push = null)
|
||||
{
|
||||
var webhook = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
if (webhookResult == null)
|
||||
{
|
||||
webhook.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(Fail());
|
||||
}
|
||||
else
|
||||
{
|
||||
webhook.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(webhookResult);
|
||||
}
|
||||
var telegram = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
|
||||
return new NotificationChannelDispatcher(webhook.Object, telegram.Object, (push ?? new Mock<IPushChannelSender>()).Object,
|
||||
Mock.Of<IEmailChannelSender>(), recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_non_push_delivery_is_recorded_and_push_is_not()
|
||||
{
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = Dispatcher(recorder, push: push);
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"), It.Is<NotificationSendResult>(x => !x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(It.IsAny<NotificationScope>(), It.Is<NotificationChannel>(c => c.Id == "push"), It.IsAny<NotificationSendResult>(), It.IsAny<ChannelSendKind>()), Times.Never);
|
||||
push.Verify(p => p.SendAsync(Tenant, It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_test_send_is_recorded_as_a_test()
|
||||
{
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
|
||||
var result = await Dispatcher(recorder).SendTestAsync(Webhook(), new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
Assert.False(result.Success);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.IsAny<NotificationChannel>(), result, ChannelSendKind.Test), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_transport_is_recorded_as_a_failure_and_the_next_channel_still_sends()
|
||||
{
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var dispatcher = Dispatcher(recorder, webhookResult: () => throw new InvalidOperationException("boom"));
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"),
|
||||
It.Is<NotificationSendResult>(x => !x.Success && x.Message == "Unexpected error: boom"), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
|
||||
}
|
||||
|
||||
// ── Read endpoint projection ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task The_health_list_follows_the_stored_channels()
|
||||
{
|
||||
var h = new Harness();
|
||||
var kept = Webhook(id: "kept");
|
||||
var moved = Webhook(id: "moved");
|
||||
var recorder = h.Recorder();
|
||||
await recorder.RecordAsync(Tenant, kept, Fail(), ChannelSendKind.Delivery);
|
||||
await recorder.RecordAsync(Tenant, kept, Fail(), ChannelSendKind.Delivery);
|
||||
await recorder.RecordAsync(Tenant, moved, Ok(), ChannelSendKind.Delivery);
|
||||
await recorder.RecordAsync(Tenant, Webhook(id: "deleted"), Ok(), ChannelSendKind.Delivery);
|
||||
|
||||
var function = new NotificationChannelHealthFunction(NullLogger<NotificationChannelHealthFunction>.Instance,
|
||||
null!, null!, h.Repository, h.Clock);
|
||||
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
|
||||
var never = Webhook(id: "never");
|
||||
var response = await function.BuildAsync(
|
||||
new[] { never, kept, Webhook(id: "moved", url: "https://hooks.example.invalid/new"), phones }, Tenant);
|
||||
|
||||
Assert.Equal(new[] { "never", "kept", "moved" }, response.Channels.Select(c => c.ChannelId));
|
||||
Assert.Equal(NotificationChannelHealthStatus.Unknown, response.Channels[0].Status);
|
||||
Assert.Equal(NotificationChannelHealthStatus.Failing, response.Channels[1].Status);
|
||||
Assert.Equal(2, response.Channels[1].RecentFailures);
|
||||
Assert.Equal("HTTP 401 Unauthorized", response.Channels[1].LastError);
|
||||
Assert.Equal(NotificationChannelHealthStatus.Unknown, response.Channels[2].Status); // destination changed since
|
||||
Assert.Null(response.Channels[2].LastAttemptUtc);
|
||||
}
|
||||
|
||||
// ── Orphan sweep (maintenance run) ────────────────────────────────────
|
||||
|
||||
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private static readonly DateTime Old = T0.AddDays(-NotificationChannelHealthMaintenance.OrphanRetentionDays - 1);
|
||||
private static readonly DateTime Recent = T0.AddDays(-NotificationChannelHealthMaintenance.OrphanRetentionDays + 1);
|
||||
|
||||
private static TenantConfiguration TenantWith(string tenantId, params string[] channelIds)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(tenantId);
|
||||
config.NotificationChannelsJson = NotificationChannel.SerializeList(channelIds.Select(id => Webhook(id: id)));
|
||||
return config;
|
||||
}
|
||||
|
||||
private static NotificationChannelHealthMaintenance Sweep(FakeRepository repository, Mock<IConfigRepository> configs)
|
||||
=> new(repository, configs.Object, NullLogger<NotificationChannelHealthMaintenance>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task The_sweep_deletes_old_rows_of_deleted_channels_and_keeps_everything_else()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed(TenantKey, "live-old", Old); // channel still configured, silent for a month
|
||||
repository.Seed(TenantKey, "gone-old", Old); // channel deleted, last send older than the grace
|
||||
repository.Seed(TenantKey, "gone-recent", Recent); // channel deleted, still inside the grace
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(TenantKey)).ReturnsAsync(TenantWith(TenantKey, "live-old", "never-sent"));
|
||||
|
||||
var result = await Sweep(repository, configs).RunAsync(T0);
|
||||
|
||||
Assert.True(repository.Has(TenantKey, "live-old"));
|
||||
Assert.False(repository.Has(TenantKey, "gone-old"));
|
||||
Assert.True(repository.Has(TenantKey, "gone-recent"));
|
||||
Assert.Equal(3, result.RowsScanned);
|
||||
Assert.Equal(1, result.RowsDeleted);
|
||||
Assert.Equal(0, result.ScopesSkipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_config_read_skips_the_scope_and_deletes_nothing_there()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed(TenantKey, "gone-old", Old);
|
||||
repository.Seed(OtherTenant, "gone-old", Old);
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(TenantKey)).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(OtherTenant)).ReturnsAsync(TenantWith(OtherTenant, "live"));
|
||||
|
||||
var result = await Sweep(repository, configs).RunAsync(T0);
|
||||
|
||||
Assert.True(repository.Has(TenantKey, "gone-old")); // read failure never reads as "no channels"
|
||||
Assert.False(repository.Has(OtherTenant, "gone-old")); // the other scope is still swept
|
||||
Assert.Equal(1, result.ScopesSkipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_a_configuration_has_no_channels_left()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed(TenantKey, "any", Old);
|
||||
repository.Seed(TenantKey, "fresh", Recent);
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(TenantKey)).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await Sweep(repository, configs).RunAsync(T0);
|
||||
|
||||
Assert.False(repository.Has(TenantKey, "any"));
|
||||
Assert.True(repository.Has(TenantKey, "fresh"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_synthesized_legacy_channel_counts_as_live()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed(TenantKey, TenantConfiguration.LegacyChannelId, Old);
|
||||
var legacy = TenantConfiguration.CreateDefault(TenantKey);
|
||||
legacy.WebhookUrl = "https://hooks.example.invalid/legacy";
|
||||
legacy.WebhookProviderType = (int)WebhookProviderType.GenericJson;
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(TenantKey)).ReturnsAsync(legacy);
|
||||
|
||||
await Sweep(repository, configs).RunAsync(T0);
|
||||
|
||||
Assert.True(repository.Has(TenantKey, TenantConfiguration.LegacyChannelId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ops_rows_follow_the_ops_channel_list_and_a_missing_admin_configuration_touches_nothing()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed("platform", "ops-live", Old);
|
||||
repository.Seed("platform", "ops-gone", Old);
|
||||
var admin = AdminConfiguration.CreateDefault();
|
||||
admin.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Webhook(id: "ops-live") });
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetAdminConfigurationAsync()).ReturnsAsync(admin);
|
||||
|
||||
await Sweep(repository, configs).RunAsync(T0);
|
||||
Assert.True(repository.Has("platform", "ops-live"));
|
||||
Assert.False(repository.Has("platform", "ops-gone"));
|
||||
|
||||
repository.Seed("platform", "ops-gone", Old);
|
||||
configs.Setup(c => c.GetAdminConfigurationAsync()).ReturnsAsync((AdminConfiguration?)null);
|
||||
var result = await Sweep(repository, configs).RunAsync(T0);
|
||||
Assert.True(repository.Has("platform", "ops-gone"));
|
||||
Assert.Equal(1, result.ScopesSkipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_row_a_send_rewrote_during_the_sweep_survives()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
repository.Seed(TenantKey, "gone-old", Old);
|
||||
var configs = new Mock<IConfigRepository>();
|
||||
configs.Setup(c => c.GetTenantConfigurationAsync(TenantKey)).ReturnsAsync(TenantWith(TenantKey));
|
||||
// A revert brought the channel back and it sent between the sweep's read and its delete.
|
||||
repository.BeforeWrite = repo => repo.Seed(TenantKey, "gone-old", T0);
|
||||
|
||||
var result = await Sweep(repository, configs).RunAsync(T0);
|
||||
|
||||
Assert.True(repository.Has(TenantKey, "gone-old"));
|
||||
Assert.Equal(0, result.RowsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_listing_never_reaches_the_maintenance_run()
|
||||
{
|
||||
var repository = new FakeRepository { Throw = true };
|
||||
|
||||
var result = await Sweep(repository, new Mock<IConfigRepository>()).RunAsync(T0);
|
||||
|
||||
Assert.Equal(0, result.RowsScanned);
|
||||
}
|
||||
|
||||
// ── Table mapping ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Mapping_roundtrips_every_field_and_omits_unset_dates()
|
||||
{
|
||||
var original = new NotificationChannelHealth
|
||||
{
|
||||
ScopeKey = TenantKey,
|
||||
ChannelId = "c1",
|
||||
Fingerprint = "0123456789abcdef",
|
||||
Recent = "1101",
|
||||
ConsecutiveFailures = 2,
|
||||
LastAttemptUtc = T0,
|
||||
LastSuccessUtc = T0.AddDays(-1),
|
||||
LastFailureUtc = T0,
|
||||
FailingSinceUtc = T0.AddHours(-1),
|
||||
LastStatusCode = 401,
|
||||
LastError = "HTTP 401 Unauthorized",
|
||||
FailingNotifiedUtc = T0,
|
||||
};
|
||||
|
||||
var back = TableNotificationChannelHealthRepository.MapFromEntity(TableNotificationChannelHealthRepository.MapToEntity(original));
|
||||
Assert.Equivalent(original, back, strict: true);
|
||||
|
||||
var sparse = TableNotificationChannelHealthRepository.MapToEntity(new NotificationChannelHealth { ScopeKey = TenantKey, ChannelId = "c2", Fingerprint = "f", Recent = "1", LastAttemptUtc = T0 });
|
||||
Assert.Null(sparse["FailingNotifiedUtc"]);
|
||||
var sparseBack = TableNotificationChannelHealthRepository.MapFromEntity(sparse);
|
||||
Assert.Null(sparseBack.LastStatusCode);
|
||||
Assert.Null(sparseBack.FailingNotifiedUtc);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -364,7 +364,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
|
||||
h.Enumerator
|
||||
.Setup(e => e.EnumerateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((_, ct) => EmptyEnumerateAsync(ct));
|
||||
.Returns<string, CancellationToken>((_, _) => EmptyEnumerateAsync());
|
||||
|
||||
h.DrainProbe.Setup(p => p.IsCascadeCompletedAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
@@ -419,8 +419,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
DrainPollCount = 0,
|
||||
};
|
||||
|
||||
private static async IAsyncEnumerable<string> EmptyEnumerateAsync(
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
private static async IAsyncEnumerable<string> EmptyEnumerateAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
|
||||
-1
@@ -247,7 +247,6 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
|
||||
Worker = new TenantOffboardingWorker(
|
||||
MainQueue.Object, PoisonQueue.Object, handler,
|
||||
BuildOpsService(),
|
||||
NullLogger<TenantOffboardingWorker>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,22 @@ public class PushChannelRoutingTests
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
{
|
||||
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel PushChannel(string id = "push") =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = true };
|
||||
@@ -43,9 +55,9 @@ public class PushChannelRoutingTests
|
||||
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
|
||||
|
||||
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,10 +65,10 @@ public class PushChannelRoutingTests
|
||||
{
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
var result = await dispatcher.SendTestAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
Assert.True(result.Success);
|
||||
push.Verify(p => p.SendWithResultAsync(NotificationScope.Platform, It.IsAny<NotificationAlert>()), Times.Once);
|
||||
|
||||
+3
-3
@@ -118,7 +118,7 @@ public class SessionDeletionPr3CodexFixesRound2Tests
|
||||
var harness = new ProducerHarness();
|
||||
harness.SetWrongState(SessionDeletionState.Preparing, "RACE-PREPARING");
|
||||
harness.SetSnapshotExists(true);
|
||||
harness.SetCas2WrongState(SessionDeletionState.Queued, "OTHER-PRODUCER-WON");
|
||||
harness.SetCas2WrongState("OTHER-PRODUCER-WON");
|
||||
|
||||
var result = await harness.Sut.EnqueueAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -147,7 +147,7 @@ public class SessionDeletionPr3CodexFixesRound2Tests
|
||||
Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
|
||||
Builder = new Mock<DeletionManifestBuilder>(
|
||||
Mock.Of<ISessionDeletionInventoryReader>(), NullLogger<DeletionManifestBuilder>.Instance);
|
||||
Mock.Of<ISessionDeletionInventoryReader>());
|
||||
|
||||
Blob = new Mock<BlobStorageService>(
|
||||
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
@@ -213,7 +213,7 @@ public class SessionDeletionPr3CodexFixesRound2Tests
|
||||
});
|
||||
}
|
||||
|
||||
public void SetCas2WrongState(string fromState, string actualCurrentState)
|
||||
public void SetCas2WrongState(string actualCurrentState)
|
||||
{
|
||||
Storage.Setup(s => s.CasSetSessionDeletionStateAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
|
||||
@@ -46,7 +46,7 @@ public class SessionDeletionPr3CodexFixesTests
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(EmptyAsyncEnumerable());
|
||||
|
||||
var builder = new DeletionManifestBuilder(reader.Object, NullLogger<DeletionManifestBuilder>.Instance);
|
||||
var builder = new DeletionManifestBuilder(reader.Object);
|
||||
|
||||
const string preAllocated = "PR3-PREALLOCATED-MANIFEST-ID";
|
||||
var manifest = await builder.BuildAsync(
|
||||
@@ -92,7 +92,7 @@ public class SessionDeletionPr3CodexFixesTests
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(EmptyAsyncEnumerable());
|
||||
|
||||
var builder = new DeletionManifestBuilder(reader.Object, NullLogger<DeletionManifestBuilder>.Instance);
|
||||
var builder = new DeletionManifestBuilder(reader.Object);
|
||||
|
||||
var manifest = await builder.BuildAsync(
|
||||
TenantId, SessionId, "preview",
|
||||
@@ -190,7 +190,7 @@ public class SessionDeletionPr3CodexFixesTests
|
||||
Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
|
||||
Builder = new Mock<DeletionManifestBuilder>(
|
||||
Mock.Of<ISessionDeletionInventoryReader>(), NullLogger<DeletionManifestBuilder>.Instance);
|
||||
Mock.Of<ISessionDeletionInventoryReader>());
|
||||
|
||||
Blob = new Mock<BlobStorageService>(
|
||||
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
|
||||
@@ -228,8 +228,7 @@ public class SessionDeletionProducerTests
|
||||
|
||||
// DeletionManifestBuilder — needs ISessionDeletionInventoryReader + ILogger.
|
||||
Builder = new Mock<DeletionManifestBuilder>(
|
||||
Mock.Of<ISessionDeletionInventoryReader>(),
|
||||
NullLogger<DeletionManifestBuilder>.Instance);
|
||||
Mock.Of<ISessionDeletionInventoryReader>());
|
||||
|
||||
// BlobStorageService — internal test ctor with managedIdentity flag.
|
||||
Blob = new Mock<BlobStorageService>(
|
||||
|
||||
@@ -15,12 +15,12 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// Server-side free-text search (<c>q=</c>) for the dashboard search box. The predicate is the
|
||||
/// search grammar of <see cref="SessionSearchQuery"/> over the web dashboard's client-side
|
||||
/// searchable field set (minus derived-only tokens). Parity with the web parser is pinned by
|
||||
/// the shared case file <c>src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json</c>,
|
||||
/// which vitest runs against <c>app/dashboard/utils/sessionSearchQuery.ts</c>.
|
||||
/// the shared case file <c>src/Web/autopilot-monitor-web/app/dashboard/lib/session-search-syntax.cases.json</c>,
|
||||
/// which vitest runs against <c>app/dashboard/lib/sessionSearchQuery.ts</c>.
|
||||
/// </summary>
|
||||
public class SessionFreeTextSearchTests
|
||||
{
|
||||
private const string CasesRepoPath = "src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json";
|
||||
private const string CasesRepoPath = "src/Web/autopilot-monitor-web/app/dashboard/lib/session-search-syntax.cases.json";
|
||||
|
||||
private static SessionSummary Sample() => new()
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Cross-language parity anchor: reflects the wire-relevant catalogs out of
|
||||
/// AutopilotMonitor.Shared (models, enums, string vocabularies) into a canonical JSON
|
||||
/// manifest committed at <c>src/Web/autopilot-monitor-web/utils/shared-manifests.json</c>.
|
||||
/// manifest committed at <c>src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json</c>.
|
||||
/// The web's vitest suite and compile-time checks compare their hand-written TS mirrors
|
||||
/// against that manifest — so a C# change that would silently drift a TS mirror first
|
||||
/// fails HERE, and the TS side fails until its mirror follows.
|
||||
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public sealed class SharedManifestParityTests
|
||||
{
|
||||
private const string ManifestRepoPath = "src/Web/autopilot-monitor-web/utils/shared-manifests.json";
|
||||
private const string ManifestRepoPath = "src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json";
|
||||
private const string WriteEnvVar = "AM_WRITE_SHARED_MANIFESTS";
|
||||
|
||||
[Fact]
|
||||
@@ -155,7 +155,7 @@ public sealed class SharedManifestParityTests
|
||||
// the portal's counters and upload budget read the same numbers the backend enforces.
|
||||
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of utils/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1012,7 +1012,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var webhook = new WebhookNotificationService(new HttpClient());
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance), Mock.Of<IPushChannelSender>());
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
@@ -235,7 +235,7 @@ public class TableLifecycleBucketTests
|
||||
/// </summary>
|
||||
private static async Task<HashSet<string>> PerSessionCascadeTablesAsync()
|
||||
{
|
||||
var builder = new DeletionManifestBuilder(new EmptyInventoryReader(), NullLogger<DeletionManifestBuilder>.Instance);
|
||||
var builder = new DeletionManifestBuilder(new EmptyInventoryReader());
|
||||
var manifest = await builder.BuildAsync(
|
||||
TenantId, SessionId, "lifecycle_bucket_test",
|
||||
new DeletionActor { Type = "admin", Actor = "alice@contoso.com" },
|
||||
|
||||
+1
-1
@@ -404,7 +404,7 @@ public class TableOffboardingAuditRepositoryTests
|
||||
mockServiceClient.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(mockTableClient.Object);
|
||||
|
||||
var storage = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
Sut = new TableOffboardingAuditRepository(storage, NullLogger<TableOffboardingAuditRepository>.Instance);
|
||||
Sut = new TableOffboardingAuditRepository(storage);
|
||||
}
|
||||
|
||||
private ETag StampEtag() => new($"\"0xFAKE_{++_etagCounter}\"");
|
||||
|
||||
@@ -18,11 +18,22 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class TelegramChannelProviderTests
|
||||
{
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
{
|
||||
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
||||
@@ -43,8 +54,8 @@ public class TelegramChannelProviderTests
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
telegram.Verify(t => t.SendAlertAsync("-1003785642894", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
@@ -60,10 +71,10 @@ public class TelegramChannelProviderTests
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform);
|
||||
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -80,8 +91,8 @@ public class TelegramChannelProviderTests
|
||||
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
||||
}, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
telegram.Verify(t => t.SendAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
@@ -99,27 +110,27 @@ public class TelegramChannelProviderTests
|
||||
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
||||
}, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendWithResult_RoutesTelegramToBot()
|
||||
public async Task SendTest_RoutesTelegramToBot()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(
|
||||
var result = await dispatcher.SendTestAsync(
|
||||
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
||||
new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
Assert.True(result.Success);
|
||||
telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
telegram.Verify(t => t.SendAlertAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Chat-ID validation ────────────────────────────────────────────────
|
||||
|
||||
@@ -36,7 +36,7 @@ public class TelemetryPayloadParserTests
|
||||
[InlineData("null")]
|
||||
public void ParseEvent_returns_null_for_missing_or_malformed_payload(string payload)
|
||||
{
|
||||
var result = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId);
|
||||
var result = TelemetryPayloadParser.ParseEvent(Dto("Event", payload));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class TelemetryPayloadParserTests
|
||||
"\"ReceivedAt\":\"2026-04-21T10:00:01Z\"" +
|
||||
"}";
|
||||
|
||||
var evt = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId);
|
||||
var evt = TelemetryPayloadParser.ParseEvent(Dto("Event", payload));
|
||||
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal("evt-123", evt!.EventId);
|
||||
|
||||
@@ -19,6 +19,7 @@ public class TenantNotificationAudienceCatalogTests
|
||||
[InlineData("sla_resolved", NotificationAudience.Member)]
|
||||
[InlineData("rule_frequency_regression", NotificationAudience.Admin)]
|
||||
[InlineData("app_version_duration_regression", NotificationAudience.Admin)]
|
||||
[InlineData("notification_channel_failing", NotificationAudience.Admin)]
|
||||
public void Resolve_KnownTypes_ReturnsExpectedAudience(string type, NotificationAudience expected)
|
||||
{
|
||||
Assert.Equal(expected, TenantNotificationAudienceCatalog.Resolve(type));
|
||||
|
||||
@@ -21,7 +21,7 @@ internal static class TestNotifications
|
||||
{
|
||||
internal static NotificationChannelDispatcher InertDispatcher()
|
||||
=> new(
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient()),
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
Mock.Of<IPushChannelSender>());
|
||||
|
||||
-2
@@ -320,11 +320,9 @@ public class VulnerabilityCorrelationServiceContributionsTests
|
||||
{
|
||||
return new VulnerabilityCorrelationService(
|
||||
vulnRepo.Object,
|
||||
sessionRepo: Mock.Of<ISessionRepository>(),
|
||||
nvdApi: null!,
|
||||
kevService: null!,
|
||||
msrcCorrelation: null!,
|
||||
adminConfig: null!,
|
||||
telemetryClient: null,
|
||||
logger: NullLogger<VulnerabilityCorrelationService>.Instance,
|
||||
opsEvents: opsEvents);
|
||||
|
||||
+2
-2
@@ -103,7 +103,7 @@ public class VulnerabilityCorrelationServiceTests
|
||||
private VulnerabilityCorrelationService CreateServiceForFilterTests()
|
||||
{
|
||||
// FilterByVersion doesn't use any dependencies — we can pass nulls
|
||||
return new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null, null!);
|
||||
return new VulnerabilityCorrelationService(null!, null!, null!, null!, null, null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -431,7 +431,7 @@ public class VulnerabilityCorrelationServiceTests
|
||||
|
||||
private VulnerabilityCorrelationService CreateServiceWithMappings(List<CpeMappingEntry> mappings)
|
||||
{
|
||||
var svc = new VulnerabilityCorrelationService(null!, null!, null!, null!, null!, null!, null, null!);
|
||||
var svc = new VulnerabilityCorrelationService(null!, null!, null!, null!, null, null!);
|
||||
svc._cpeMappings = mappings;
|
||||
return svc;
|
||||
}
|
||||
|
||||
@@ -222,10 +222,9 @@ public class WebhookConnectGateTests
|
||||
public async Task TestNotification_ReportsTheRefusal()
|
||||
{
|
||||
var resolver = new CountingResolver("10.0.0.8");
|
||||
var webhook = new WebhookNotificationService(ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve)),
|
||||
NullLogger<WebhookNotificationService>.Instance);
|
||||
var webhook = new WebhookNotificationService(ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve)));
|
||||
|
||||
var result = await webhook.SendNotificationWithResultAsync($"https://{Host}/hook", WebhookProviderType.GenericJson,
|
||||
var result = await webhook.SendAsync($"https://{Host}/hook", WebhookProviderType.GenericJson,
|
||||
new NotificationAlert { Title = "T", Summary = "S" });
|
||||
|
||||
Assert.False(result.Success);
|
||||
|
||||
@@ -87,7 +87,7 @@ public class WhatsNewNotificationServiceTests
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var webhook = new WebhookNotificationService(new HttpClient());
|
||||
var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance);
|
||||
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, telegram, Mock.Of<IPushChannelSender>());
|
||||
Dispatcher
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
// What's new notifier watermark (single platform row in the AdminConfiguration table).
|
||||
services.AddSingleton<IWhatsNewNotificationStateRepository, TableWhatsNewNotificationStateRepository>();
|
||||
services.AddSingleton<IHardwareRejectionNotificationTracker, TableHardwareRejectionNotificationTracker>();
|
||||
services.AddSingleton<INotificationChannelHealthRepository, TableNotificationChannelHealthRepository>();
|
||||
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
|
||||
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
|
||||
services.AddSingleton<IDelegationInvitationRepository, TableDelegationInvitationRepository>();
|
||||
|
||||
+1
-4
@@ -21,14 +21,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public sealed class TableConfigBackupRepository : IConfigBackupRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableConfigBackupRepository> _logger;
|
||||
|
||||
public TableConfigBackupRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableConfigBackupRepository> logger)
|
||||
TableStorageService storage)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.ConfigurationBackups);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task UpsertAsync(ConfigBackupEntry entry, CancellationToken ct = default)
|
||||
|
||||
+1
-3
@@ -18,12 +18,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public class TableMaintenanceRepository : IMaintenanceRepository
|
||||
{
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableMaintenanceRepository> _logger;
|
||||
|
||||
public TableMaintenanceRepository(TableStorageService storage, ILogger<TableMaintenanceRepository> logger)
|
||||
public TableMaintenanceRepository(TableStorageService storage)
|
||||
{
|
||||
_storage = storage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType,
|
||||
|
||||
+1
-3
@@ -11,12 +11,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public class TableMetricsRepository : IMetricsRepository
|
||||
{
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly IDataEventPublisher _publisher;
|
||||
|
||||
public TableMetricsRepository(TableStorageService storage, IDataEventPublisher publisher)
|
||||
public TableMetricsRepository(TableStorageService storage)
|
||||
{
|
||||
_storage = storage;
|
||||
_publisher = publisher;
|
||||
}
|
||||
|
||||
public Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics)
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table Storage implementation of <see cref="INotificationChannelHealthRepository"/>.
|
||||
/// PartitionKey = scope key, RowKey = channel id. Whole-row Replace (never Merge), so a
|
||||
/// cleared field such as <see cref="NotificationChannelHealth.FailingNotifiedUtc"/> really clears.
|
||||
/// </summary>
|
||||
public class TableNotificationChannelHealthRepository : INotificationChannelHealthRepository
|
||||
{
|
||||
private readonly TableClient _table;
|
||||
|
||||
public TableNotificationChannelHealthRepository(TableStorageService storage)
|
||||
{
|
||||
_table = storage.GetTableClient(Constants.TableNames.NotificationChannelHealth);
|
||||
}
|
||||
|
||||
public async Task<(NotificationChannelHealth? Row, string? ETag)> GetWithETagAsync(string scopeKey, string channelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _table.GetEntityAsync<TableEntity>(scopeKey, channelId).ConfigureAwait(false);
|
||||
return (MapFromEntity(entity.Value), entity.Value.ETag.ToString());
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryUpsertAsync(NotificationChannelHealth row, string? ifMatchETag)
|
||||
{
|
||||
var entity = MapToEntity(row);
|
||||
try
|
||||
{
|
||||
if (ifMatchETag is null)
|
||||
await _table.AddEntityAsync(entity).ConfigureAwait(false);
|
||||
else
|
||||
await _table.UpdateEntityAsync(entity, new ETag(ifMatchETag), TableUpdateMode.Replace).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 412)
|
||||
{
|
||||
// 409: a concurrent first writer created the row; 412: the row changed since the read.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<NotificationChannelHealth>> ListAsync(string scopeKey)
|
||||
{
|
||||
var result = new List<NotificationChannelHealth>();
|
||||
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {scopeKey}");
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter).ConfigureAwait(false))
|
||||
result.Add(MapFromEntity(entity));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Table not created yet: no channel has ever sent.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<(NotificationChannelHealth Row, string ETag)>> ListAllAsync()
|
||||
{
|
||||
var result = new List<(NotificationChannelHealth, string)>();
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>().ConfigureAwait(false))
|
||||
result.Add((MapFromEntity(entity), entity.ETag.ToString()));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Table not created yet: nothing to sweep.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> TryDeleteAsync(NotificationChannelHealth row, string ifMatchETag)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _table.DeleteEntityAsync(row.ScopeKey, row.ChannelId, new ETag(ifMatchETag)).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404 || ex.Status == 412)
|
||||
{
|
||||
// 404: already gone; 412: a send rewrote the row since the sweep read it.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
internal static TableEntity MapToEntity(NotificationChannelHealth h)
|
||||
{
|
||||
return new TableEntity(h.ScopeKey, h.ChannelId)
|
||||
{
|
||||
{ "Fingerprint", h.Fingerprint },
|
||||
{ "Recent", h.Recent },
|
||||
{ "ConsecutiveFailures", h.ConsecutiveFailures },
|
||||
{ "LastAttemptUtc", Utc(h.LastAttemptUtc) },
|
||||
{ "LastSuccessUtc", Utc(h.LastSuccessUtc) },
|
||||
{ "LastFailureUtc", Utc(h.LastFailureUtc) },
|
||||
{ "FailingSinceUtc", Utc(h.FailingSinceUtc) },
|
||||
{ "LastStatusCode", h.LastStatusCode },
|
||||
{ "LastError", h.LastError },
|
||||
{ "FailingNotifiedUtc", Utc(h.FailingNotifiedUtc) },
|
||||
};
|
||||
}
|
||||
|
||||
internal static NotificationChannelHealth MapFromEntity(TableEntity e)
|
||||
{
|
||||
return new NotificationChannelHealth
|
||||
{
|
||||
ScopeKey = e.PartitionKey,
|
||||
ChannelId = e.RowKey,
|
||||
Fingerprint = e.GetString("Fingerprint") ?? string.Empty,
|
||||
Recent = e.GetString("Recent") ?? string.Empty,
|
||||
ConsecutiveFailures = e.GetInt32("ConsecutiveFailures") ?? 0,
|
||||
LastAttemptUtc = e.GetDateTime("LastAttemptUtc"),
|
||||
LastSuccessUtc = e.GetDateTime("LastSuccessUtc"),
|
||||
LastFailureUtc = e.GetDateTime("LastFailureUtc"),
|
||||
FailingSinceUtc = e.GetDateTime("FailingSinceUtc"),
|
||||
LastStatusCode = e.GetInt32("LastStatusCode"),
|
||||
LastError = e.GetString("LastError"),
|
||||
FailingNotifiedUtc = e.GetDateTime("FailingNotifiedUtc"),
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? Utc(DateTime? value)
|
||||
=> value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null;
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -28,14 +28,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public class TableOffboardingAuditRepository : IOffboardingAuditRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableOffboardingAuditRepository> _logger;
|
||||
|
||||
public TableOffboardingAuditRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableOffboardingAuditRepository> logger)
|
||||
TableStorageService storage)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.OffboardingAudit);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Marker ──────────────────────────────────────────────────────────────
|
||||
|
||||
+1
-4
@@ -30,14 +30,11 @@ public sealed class TableScriptNameCacheRepository : IScriptNameCacheRepository
|
||||
private const string MetaRowKeySuffix = "_$meta";
|
||||
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableScriptNameCacheRepository> _logger;
|
||||
|
||||
public TableScriptNameCacheRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableScriptNameCacheRepository> logger)
|
||||
TableStorageService storage)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.ScriptNameCache);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<ScriptRef, ScriptDisplayNameEntry>> GetManyAsync(
|
||||
|
||||
+1
-4
@@ -21,14 +21,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public sealed class TableTenantCustomsArchiveRepository : ITenantCustomsArchiveRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableTenantCustomsArchiveRepository> _logger;
|
||||
|
||||
public TableTenantCustomsArchiveRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableTenantCustomsArchiveRepository> logger)
|
||||
TableStorageService storage)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.TenantOffboardingCustomsArchive);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task UpsertAsync(TenantOffboardingCustomsArchiveEntry entry, CancellationToken ct = default)
|
||||
|
||||
+1
-3
@@ -18,13 +18,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly IDataEventPublisher _publisher;
|
||||
private readonly ILogger<TableVulnerabilityRepository> _logger;
|
||||
|
||||
public TableVulnerabilityRepository(TableStorageService storage, IDataEventPublisher publisher, ILogger<TableVulnerabilityRepository> logger)
|
||||
public TableVulnerabilityRepository(TableStorageService storage, IDataEventPublisher publisher)
|
||||
{
|
||||
_storage = storage;
|
||||
_publisher = publisher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// --- Vulnerability Reports (delegate to existing service) ---
|
||||
|
||||
-3
@@ -27,7 +27,6 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
internal static readonly TimeSpan AccessCheckBudget = TimeSpan.FromSeconds(4);
|
||||
|
||||
private readonly ILogger<AutopilotDeviceValidationConsentFunction> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly GraphTokenService _graphTokenService;
|
||||
private readonly IGraphFeatureDetector _graphFeatureDetector;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
@@ -49,7 +48,6 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
|
||||
public AutopilotDeviceValidationConsentFunction(
|
||||
ILogger<AutopilotDeviceValidationConsentFunction> logger,
|
||||
IConfiguration configuration,
|
||||
GraphTokenService graphTokenService,
|
||||
IGraphFeatureDetector graphFeatureDetector,
|
||||
TelemetryClient telemetryClient,
|
||||
@@ -60,7 +58,6 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
{
|
||||
_appHomingService = appHomingService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_graphTokenService = graphTokenService;
|
||||
_graphFeatureDetector = graphFeatureDetector;
|
||||
_telemetryClient = telemetryClient;
|
||||
|
||||
-3
@@ -18,16 +18,13 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
/// </summary>
|
||||
public class DelegationSelfServiceFunction
|
||||
{
|
||||
private readonly ILogger<DelegationSelfServiceFunction> _logger;
|
||||
private readonly DelegationSelfService _svc;
|
||||
private readonly DelegatedSlotService _slots;
|
||||
|
||||
public DelegationSelfServiceFunction(
|
||||
ILogger<DelegationSelfServiceFunction> logger,
|
||||
DelegationSelfService svc,
|
||||
DelegatedSlotService slots)
|
||||
{
|
||||
_logger = logger;
|
||||
_svc = svc;
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
private readonly MaintenanceService _maintenanceService;
|
||||
private readonly Services.Push.PushMaintenanceService _pushMaintenance;
|
||||
private readonly Services.Notifications.NotificationChannelHealthMaintenance _channelHealthMaintenance;
|
||||
private readonly ILogger<MaintenanceFunction> _logger;
|
||||
|
||||
public MaintenanceFunction(
|
||||
MaintenanceService maintenanceService,
|
||||
Services.Push.PushMaintenanceService pushMaintenance,
|
||||
Services.Notifications.NotificationChannelHealthMaintenance channelHealthMaintenance,
|
||||
ILogger<MaintenanceFunction> logger)
|
||||
{
|
||||
_maintenanceService = maintenanceService;
|
||||
_pushMaintenance = pushMaintenance;
|
||||
_channelHealthMaintenance = channelHealthMaintenance;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -40,6 +43,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
await _maintenanceService.RunAllAsync();
|
||||
// Push channel retention rides on this timer (no timer of its own — K27); fail-soft inside.
|
||||
await _pushMaintenance.RunAsync();
|
||||
// Health rows of deleted notification channels (D-342); fail-soft inside.
|
||||
await _channelHealthMaintenance.RunAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -23,16 +23,13 @@ public class McpClientRegistrationFunction
|
||||
/// <summary>Per source IP and minute. Every lookup comes from the MCP server's egress IP, which caches answers for a minute.</summary>
|
||||
internal const int LookupRateLimitPerMinute = 120;
|
||||
|
||||
private readonly ILogger<McpClientRegistrationFunction> _logger;
|
||||
private readonly McpClientRegistrationService _service;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public McpClientRegistrationFunction(
|
||||
ILogger<McpClientRegistrationFunction> logger,
|
||||
McpClientRegistrationService service,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
private readonly GitHubRuleRepository _gitHubRepo;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
private readonly ImeLogPatternService _imeLogPatternService;
|
||||
private readonly IVulnerabilityRepository _vulnRepo;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
|
||||
@@ -31,7 +30,6 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
GitHubRuleRepository gitHubRepo,
|
||||
GatherRuleService gatherRuleService,
|
||||
AnalyzeRuleService analyzeRuleService,
|
||||
ImeLogPatternService imeLogPatternService,
|
||||
IVulnerabilityRepository vulnRepo,
|
||||
IRuleRepository ruleRepo)
|
||||
{
|
||||
@@ -39,7 +37,6 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
_gitHubRepo = gitHubRepo;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
_imeLogPatternService = imeLogPatternService;
|
||||
_vulnRepo = vulnRepo;
|
||||
_ruleRepo = ruleRepo;
|
||||
}
|
||||
|
||||
+1
-3
@@ -20,12 +20,10 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
public class GetBackupJobStatusFunction
|
||||
{
|
||||
private readonly BackupJobsRepository _jobs;
|
||||
private readonly ILogger<GetBackupJobStatusFunction> _logger;
|
||||
|
||||
public GetBackupJobStatusFunction(BackupJobsRepository jobs, ILogger<GetBackupJobStatusFunction> logger)
|
||||
public GetBackupJobStatusFunction(BackupJobsRepository jobs)
|
||||
{
|
||||
_jobs = jobs;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("GetBackupJobStatus")]
|
||||
|
||||
+1
-3
@@ -19,12 +19,10 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
public class GetBackupManifestFunction
|
||||
{
|
||||
private readonly BlobBackupStore _store;
|
||||
private readonly ILogger<GetBackupManifestFunction> _logger;
|
||||
|
||||
public GetBackupManifestFunction(BlobBackupStore store, ILogger<GetBackupManifestFunction> logger)
|
||||
public GetBackupManifestFunction(BlobBackupStore store)
|
||||
{
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("GetBackupManifest")]
|
||||
|
||||
@@ -20,12 +20,10 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
public class ListBackupsFunction
|
||||
{
|
||||
private readonly BlobBackupStore _store;
|
||||
private readonly ILogger<ListBackupsFunction> _logger;
|
||||
|
||||
public ListBackupsFunction(BlobBackupStore store, ILogger<ListBackupsFunction> logger)
|
||||
public ListBackupsFunction(BlobBackupStore store)
|
||||
{
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("ListBackups")]
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Delivery status of the stored notification channels: a tenant's channels and the
|
||||
/// platform's ops channels. Same audience as the channels' "Send Test" button — the people
|
||||
/// who can fix a failing destination.
|
||||
/// </summary>
|
||||
public class NotificationChannelHealthFunction
|
||||
{
|
||||
private readonly ILogger<NotificationChannelHealthFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly INotificationChannelHealthRepository _repository;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public NotificationChannelHealthFunction(
|
||||
ILogger<NotificationChannelHealthFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
INotificationChannelHealthRepository repository,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_repository = repository;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
[Function("GetNotificationChannelHealth")]
|
||||
public async Task<HttpResponseData> GetTenant(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/notification-channel-health")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var targetTenantId = req.GetRequestContext().TargetTenantId;
|
||||
var config = await _configService.GetConfigurationAsync(targetTenantId);
|
||||
var body = await BuildAsync(config.GetNotificationChannels(), NotificationScope.Tenant(targetTenantId));
|
||||
return await req.OkAsync(body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetNotificationChannelHealth");
|
||||
}
|
||||
}
|
||||
|
||||
[Function("GetOpsChannelHealth")]
|
||||
public async Task<HttpResponseData> GetOps(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/config/ops-channel-health")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
var body = await BuildAsync(config.GetOpsNotificationChannels(), NotificationScope.Platform);
|
||||
return await req.OkAsync(body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetOpsChannelHealth");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry per stored non-Push channel, in channel order. Rows of deleted channels are
|
||||
/// never returned, and a row of an earlier destination reads as no deliveries yet.
|
||||
/// </summary>
|
||||
internal async Task<NotificationChannelHealthResponse> BuildAsync(IEnumerable<NotificationChannel> channels, NotificationScope scope)
|
||||
{
|
||||
var rows = (await _repository.ListAsync(scope.Key))
|
||||
.GroupBy(r => r.ChannelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
|
||||
var response = new NotificationChannelHealthResponse();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (channel == null || string.IsNullOrWhiteSpace(channel.Id)
|
||||
|| channel.ProviderType == (int)WebhookProviderType.Push)
|
||||
continue;
|
||||
|
||||
rows.TryGetValue(channel.Id, out var row);
|
||||
var current = NotificationChannelHealthEvaluator.Current(row, NotificationChannelHealthEvaluator.Fingerprint(channel));
|
||||
response.Channels.Add(NotificationChannelHealthEvaluator.ToDto(channel.Id, current, now));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
|
||||
var testAlert = NotificationAlertBuilder.BuildTestAlert();
|
||||
var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert, NotificationScope.Platform);
|
||||
var result = await _channelDispatcher.SendTestAsync(channel, testAlert, NotificationScope.Platform);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestWebhookNotificationResponse
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
|
||||
var testAlert = NotificationAlertBuilder.BuildTestAlert();
|
||||
var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert, NotificationScope.Tenant(requestCtx.TargetTenantId));
|
||||
var result = await _channelDispatcher.SendTestAsync(channel, testAlert, NotificationScope.Tenant(requestCtx.TargetTenantId));
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestWebhookNotificationResponse
|
||||
|
||||
@@ -14,14 +14,11 @@ namespace AutopilotMonitor.Functions.Functions.Global;
|
||||
/// </summary>
|
||||
public class GlobalNotificationsFunction
|
||||
{
|
||||
private readonly ILogger<GlobalNotificationsFunction> _logger;
|
||||
private readonly GlobalNotificationService _notificationService;
|
||||
|
||||
public GlobalNotificationsFunction(
|
||||
ILogger<GlobalNotificationsFunction> logger,
|
||||
GlobalNotificationService notificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
|
||||
-3
@@ -41,7 +41,6 @@ public class GetGraphPermissionsStatusFunction
|
||||
|
||||
private readonly ILogger<GetGraphPermissionsStatusFunction> _logger;
|
||||
private readonly IGraphFeatureDetector _detector;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TelemetryClient _telemetry;
|
||||
private readonly Security.EntraAppRegistry _appRegistry;
|
||||
private readonly Services.TenantConfigurationService _tenantConfigService;
|
||||
@@ -49,14 +48,12 @@ public class GetGraphPermissionsStatusFunction
|
||||
public GetGraphPermissionsStatusFunction(
|
||||
ILogger<GetGraphPermissionsStatusFunction> logger,
|
||||
IGraphFeatureDetector detector,
|
||||
IConfiguration configuration,
|
||||
TelemetryClient telemetry,
|
||||
Security.EntraAppRegistry appRegistry,
|
||||
Services.TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_detector = detector;
|
||||
_configuration = configuration;
|
||||
_telemetry = telemetry;
|
||||
_appRegistry = appRegistry;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
|
||||
@@ -499,7 +499,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
switch (kind)
|
||||
{
|
||||
case TelemetryItemKind.Event:
|
||||
parsed = Add(batch.Events, TelemetryPayloadParser.ParseEvent(item, tenantId, sessionId));
|
||||
parsed = Add(batch.Events, TelemetryPayloadParser.ParseEvent(item));
|
||||
break;
|
||||
case TelemetryItemKind.Signal:
|
||||
parsed = Add(batch.Signals, TelemetryPayloadParser.ParseSignal(item, tenantId, sessionId));
|
||||
|
||||
@@ -15,10 +15,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses an <c>Event</c> item's payload into an <see cref="EnrollmentEvent"/>.
|
||||
/// TenantId/SessionId on the event are authoritative from the caller; any agent-supplied
|
||||
/// values are overwritten by <see cref="IngestTelemetryFunction"/>.
|
||||
/// TenantId/SessionId are not set here: the ingest path behind
|
||||
/// <see cref="IngestTelemetryFunction"/> stamps them from the authenticated request,
|
||||
/// overwriting any agent-supplied values.
|
||||
/// </summary>
|
||||
public static EnrollmentEvent? ParseEvent(TelemetryItemDto item, string tenantId, string sessionId)
|
||||
public static EnrollmentEvent? ParseEvent(TelemetryItemDto item)
|
||||
{
|
||||
if (string.IsNullOrEmpty(item?.PayloadJson)) return null;
|
||||
try
|
||||
|
||||
+2
-3
@@ -17,8 +17,8 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
|
||||
/// (24h look-back matching the daily cadence).</item>
|
||||
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
|
||||
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
|
||||
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the
|
||||
/// <see cref="ExpiringHeadsUp"/> window (re-emitted each daily run until expiry —
|
||||
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
|
||||
/// <item><c>TenantRetentionGraceExpiring</c> / <c>TenantRetentionGraceEnded</c> (Warning) —
|
||||
/// same window mechanics for the retention downgrade grace
|
||||
@@ -34,7 +34,6 @@ public class TrialExpirySweepFunction
|
||||
{
|
||||
/// <summary>Heads-up window for TenantTrialExpiring.</summary>
|
||||
public static readonly TimeSpan ExpiringHeadsUp = TimeSpan.FromDays(3);
|
||||
private const int ExpiringHeadsUpDays = 3;
|
||||
|
||||
/// <summary>Look-back for TenantTrialExpired — matches the daily cadence so no expiry is skipped.</summary>
|
||||
public static readonly TimeSpan ExpiredLookBack = TimeSpan.FromHours(24);
|
||||
|
||||
-3
@@ -16,14 +16,11 @@ namespace AutopilotMonitor.Functions.Functions.Notifications;
|
||||
/// </summary>
|
||||
public class TenantNotificationsFunction
|
||||
{
|
||||
private readonly ILogger<TenantNotificationsFunction> _logger;
|
||||
private readonly TenantNotificationService _notificationService;
|
||||
|
||||
public TenantNotificationsFunction(
|
||||
ILogger<TenantNotificationsFunction> logger,
|
||||
TenantNotificationService notificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// </summary>
|
||||
public class AnalyzeRulesFunction
|
||||
{
|
||||
private readonly ILogger<AnalyzeRulesFunction> _logger;
|
||||
private readonly AnalyzeRuleService _ruleService;
|
||||
|
||||
public AnalyzeRulesFunction(
|
||||
ILogger<AnalyzeRulesFunction> logger,
|
||||
AnalyzeRuleService ruleService)
|
||||
{
|
||||
_logger = logger;
|
||||
_ruleService = ruleService;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// </summary>
|
||||
public class GatherRulesFunction
|
||||
{
|
||||
private readonly ILogger<GatherRulesFunction> _logger;
|
||||
private readonly GatherRuleService _ruleService;
|
||||
|
||||
public GatherRulesFunction(
|
||||
ILogger<GatherRulesFunction> logger,
|
||||
GatherRuleService ruleService)
|
||||
{
|
||||
_logger = logger;
|
||||
_ruleService = ruleService;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user