Compare commits

..

23 Commits

Author SHA1 Message Date
dependabot[bot] 51aaa2e80a chore(deps): bump the mcp group (#183)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/node](https://github.com/modelcontextprotocol/typescript-sdk) | `2.1.0` | `2.1.1` |
| [@modelcontextprotocol/server](https://github.com/modelcontextprotocol/typescript-sdk) | `2.2.0` | `2.3.0` |
| [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk) | `2.2.0` | `2.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.20.4` | `22.20.5` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.37.0` | `6.39.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.71.0` |


Updates `@modelcontextprotocol/node` from 2.1.0 to 2.1.1
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/node@2.1.0...@modelcontextprotocol/node@2.1.1)

Updates `@modelcontextprotocol/server` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/server@2.2.0...@modelcontextprotocol/server@2.3.0)

Updates `@modelcontextprotocol/client` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/client@2.2.0...@modelcontextprotocol/client@2.3.0)

Updates `@types/node` from 22.20.4 to 22.20.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `knip` from 6.37.0 to 6.39.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.39.0/packages/knip)

Updates `typescript-eslint` from 8.65.0 to 8.71.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/node"
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/server"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/client"
  dependency-version: 2.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 18:59:41 +02:00
dependabot[bot] 0a6779cd26 chore(deps-dev): bump the web group (#182)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `@types/node` from 22.20.4 to 22.20.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `knip` from 6.37.0 to 6.39.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.39.0/packages/knip)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.20.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 18:59:27 +02:00
dependabot[bot] 1a1c2b413e chore(deps-dev): bump jscpd (#181)
Bumps the quality-tooling group in /tools/quality with 1 update: [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd).


Updates `jscpd` from 5.3.0 to 5.4.0
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Commits](https://github.com/kucherenko/jscpd/commits/v5.4.0/rust/jscpd)

---
updated-dependencies:
- dependency-name: jscpd
  dependency-version: 5.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: quality-tooling
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 18:59:15 +02:00
Oliver Kieselbach 5e763375b4 bump pointer: internal, .claude/commands (dead-code and duplicate gates built: D-343..D-345)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:33:20 +02:00
Oliver Kieselbach 9f4f456dc5 CLAUDE.md: search for an existing helper by behaviour; run the dead-code and duplicate gates
Names the helper homes (web lib/, backend Functions/Helpers/) and asks for a
search by what a helper does before writing a new one, which no tool can judge.
Lists the local commands for the new gates, since CI only reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:10:32 +02:00
Oliver Kieselbach af2f2c7abe CI: duplicate-code check over src/ with a baseline that only shrinks
tools/quality runs jscpd (5.3.0, pinned with a lockfile like rules/scripts)
over the C# and TypeScript sources, tests and generated files excluded, at 100
tokens. The 95 existing clones are frozen in jscpd-baseline.json. A clone that
is not in the baseline fails, and so does a baseline entry whose clone is gone:
jscpd only reports missing fingerprints, and a stale one would let the same
copy come back unnoticed.

jscpd scans an LF-normalized copy of the tracked and new files under src/,
because with CRLF in a Windows checkout some JSX clones hash differently than
on the Linux runner; local runs and CI now see the same 95 clones.

At 70 tokens a sixth of all clones were identical using blocks, hence 100.
Gate proven both ways with an untracked probe file: a third copy of an existing
helper fails with its location, and a baseline entry without its clone fails.

New CI job "Duplicate code"; Dependabot watches tools/quality with the usual
7-day cooldown. Locally: npm run dup (check), npm run dup:baseline (shrink).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:10:07 +02:00
Oliver Kieselbach 98d87a8579 Build: unused private members, unread fields and unused parameters fail the .NET build
The root .editorconfig raises IDE0051, IDE0052 and IDE0060 (non-public
methods) to warning, and Directory.Build.props sets EnforceCodeStyleInBuild,
so the warning freeze turns them into build errors in every project, the net48
agent included.

Cleanup to start at zero, each finding checked for leftover vs. missing
behaviour (none was a feature that silently never ran):
- 38 injected dependencies nobody read: loggers, configuration, and services
  whose last read had moved to a live replacement (the IME reseed writes
  through the rule repository, the offboarding poison OpsEvent comes from the
  handler, the vulnerability gate sits in the correlate handler). Removed with
  their constructor parameters and the argument at every construction site.
- unused private members: SelfUpdater.IsNewerVersion, a GatherRuleExecutor
  wrapper, test constants and an unused test overload.
  RjPhaseCompletedFirstDeployment now replaces the two literal 110s it names.
- unused parameters removed together with their arguments; test fakes bound as
  method groups use discards.
- the commented-out backend-URL connectivity check and its plumbing are gone;
  the reason the backend stays out remains as a comment.
- DiagnosticsPackageService names the blob in its upload-failure warnings, as
  its doc already said.
- stale docs fixed on the way: OnboardedAt no longer claims a maintenance
  backfill (shared manifest and wire types regenerated), the vulnerability
  functions' policy comments match the catalog, a misplaced summary moved to
  its method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:03:00 +02:00
Oliver Kieselbach 0709ce2f76 Web, MCP: knip gates unused files, exports and dependencies
knip (6.37.0) runs in both TypeScript projects as its own CI step
(npm run knip) next to ESLint. It builds the module graph, so "nothing
imports this" is checked against every reference instead of a search.
Generated files stay out of scope (lib/generated, src/generated); the
MCP's Dockerfile-run precompute and its manual scripts are entries, and so
is the web's compile-time manifest check.

Cleanup to start at zero:
- web: four orphaned components (AdminPageSections, SettingsMenu, UserMenu
  (the navbar renders its own menu) and MarkFailedModal), 17 unused sidebar
  icons, four unused config constants (the agent-download host allowlist lost
  its only reader with the /go route in July), the push device-status union
  that existed twice and was used nowhere, an uncalled push error helper,
  unused barrel re-exports, default exports duplicating named ones, four
  superseded DTO interfaces, and a JSDoc type import of an undeclared package.
- mcp: two test helpers nothing calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:56:23 +02:00
Oliver Kieselbach 69d876b0a6 MCP: lint and type-check tests and scripts like the web app
The MCP server had no ESLint, and since its tests left the tsconfig (to keep
them out of dist/) nothing type-checked them; scripts/ never was. tsconfig.json
now type-checks sources, tests and scripts (noEmit); tsconfig.build.json emits
the same dist/ as before (byte-identical) for npm run build and the Docker
image. ESLint (the web's versions: eslint 9.39.5, typescript-eslint 8.65.0)
runs the recommended rules plus the typed promise rules, and CI's mcp job runs
it with --max-warnings 0.

Findings fixed: 16 type errors in tests (a fixture missing three wire fields,
unknown catch results, untyped mocks); gracefulShutdown was async without
awaiting anything, so the signal handlers returned a promise; two dead imports;
a Function cast replaced by the exact pipeline() signature; an unused test
variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:42:13 +02:00
Oliver Kieselbach 224eb668a1 Web: helpers live in lib/, generated files in lib/generated/; utils/ is gone
The web app kept helpers in two root folders (lib/ and utils/) and named
route-local helper folders either utils/ or lib/. Shared helpers now live in
lib/, route-only helpers in a lib/ next to their route (app/dashboard/lib,
app/sessions/lib). Generated wire types, manifests, guardrails, error codes and
the lastmod file move to lib/generated/, like the MCP server's src/generated/.
The session-search parity cases sit next to sessionSearchQuery.ts.

Pure move, no behaviour change. Imports, the generators (combine.js,
generate-shared-manifest-types.js, sync-error-codes.js, generate-lastmod.js),
the CI drift paths, the two backend tests and the MCP freshness test that read
these files, and the guard tests' registry path and scan list follow the new
locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:29:57 +02:00
Oliver Kieselbach a6980a93f6 bump pointer: internal (notification channel health released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:06:37 +02:00
Oliver Kieselbach eb57980b53 bump pointer: internal, .claude/commands (channel health orphan sweep)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:28 +02:00
Oliver Kieselbach c4c2415de8 Notification channel health: the maintenance run deletes the rows of deleted channels (D-342)
A health row goes once its channel is no longer in the stored channel list of its scope and it
saw no send for 30 days. The channel lists are read strictly, so a failed read skips the scope
instead of reading as "no channels"; the delete is ETag-conditional, so a row a send just
rewrote survives. Runs on the 2-hourly maintenance timer next to the push retention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:27 +02:00
Oliver Kieselbach 9331366025 bump pointer: internal, .claude/commands (notification channel health: D-342, marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:44 +02:00
Oliver Kieselbach 85a0cb3c31 Notification channels: per-channel delivery status and a bell when a channel keeps failing (D-342)
Every non-Push send, real or test, is recorded in a NotificationChannelHealth row per channel
(own table, server-side status rule, destination fingerprint resets the history). The settings
show a status dot per channel: Operating normally, Failure rate x%, Error with the cause, or No
deliveries yet. Two failed deliveries in a row raise one bell for the tenant admins (or the GA
bell for ops channels) per failing episode; a failing channel keeps receiving deliveries.

Each transport now has one send path that reports its outcome (WebhookTestResult is
NotificationSendResult, the dispatcher's test entry is SendTestAsync); failures are logged with
tenant and channel id. New routes GET config/{tenantId}/notification-channel-health
(TenantAdminOrGA) and GET global/config/ops-channel-health (GlobalAdminOnly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:43 +02:00
Oliver Kieselbach 5efa7d078c bump pointer: internal (global fan-out released, first measurements)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:18:18 +02:00
Oliver Kieselbach 35d1445ebb bump pointer: internal, .claude/commands (global fan-out built: D-340/D-341, health markers)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:01:30 +02:00
Oliver Kieselbach 23cd9050ea Global fan-out: session-derived cross-tenant reads run over enrolled tenants, GA follow-up pages fetch 100 (D-340)
- TableStorageService.GetEnrolledTenantIdsCachedAsync: configured tenants with a PlatformStats counter row (RowKey 'current', TotalEnrollments > 0), 60 s single-flight; fail-open to the configured list with a Warning when the counter scan fails
- Session list pages, cross-tenant stats drain, geo window and app-summary snapshot fan out over that subset (353 -> 127 partition queries per page live); fresh-install fallbacks key on the configured list, a configured set nobody enrolled in is an empty page; audit logs, QuerySessionsByDateRangeAsync and maintenance keep the full list
- Web: cross-tenant follow-up pages fetch max(pageSize, 100) (resolveSessionsFetchPageSize); first page and own-tenant list unchanged
- Tests: CrossTenantFanOutTests (enrolled subset, counter 0 / no row, fail-open, unconfigured counter row, empty enrolled set never the legacy scan, bound after the filter, geo + apps), DelegatedBoundedAggregateTests harness, sessionsPageSize.test.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:00:24 +02:00
Oliver Kieselbach 8615fb458f bump pointer: internal (global fan-out audit for B-gdd)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 13:20:03 +02:00
Oliver Kieselbach 645f747191 bump pointer: internal (orphan sweep released, first measurements)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 00:42:32 +02:00
Oliver Kieselbach 5934ae9fc4 bump pointer: internal, .claude/commands (orphan sweep built: D-339, concept doc, health markers)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:56:30 +02:00
Oliver Kieselbach 3d10011460 Orphan sessions: ingest refuses sessions without a row, the sweep becomes their cascade in its own function (D-339)
A session without a Sessions row can never be reached by the cascade, so every row written for it was an orphan by construction. Three layers now keep the platform clean:

- Prevent: IngestTelemetry answers a batch for an unknown session with 410 (same status and body as the cascade lock; request-row marker IngestRefusal = session_unknown | cascade_locked). The derived writers - vulnerability correlation, analyze, manual rescan - stop on SessionDeletionGuard.GetLivenessAsync != Live. The agent treats every 410 as terminal: one TelemetryRejectedSessionUnknown report (exempt from the reporter cooldown), then terminate_session with forced self-destruct (origin session_gone, no final drain, no diagnostics upload); spool items of a foreign session id are dropped at startup and never terminate the current session.
- Detect: OrphanSessionSweepFunction (every 4h at :45, own lease) diffs the Sessions keys against the EventSessionIndex handles - two range drains instead of ~16.8k Sessions point reads per 2h maintenance run - and re-checks every candidate (handle unchanged, row still absent, tombstone) moments before deleting. The in-maintenance sweep raced the retention cascade on their shared 00:00/12:00 tick and reported false orphans; it is gone.
- Clean: every table of the cascade manifest minus the tombstone pair (ratchet in TableLifecycleBucketTests), the inventory counters with the stamp on the handle (claim, then decrement), the handle last with If-Match. A weekly read-only reconciliation of EventTypeIndex against the Sessions keys (Sunday mornings UTC, and on every manual maintenance run) reports residue.

MaintenanceCompleted now carries stepsMs. MCP search_sessions_by_event auto-exhausts empty-but-continuable pages. Manifests regenerated for the new ops event types and AdminConfiguration.OrphanDeepCheckLastRunUtc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:55:27 +02:00
Oliver Kieselbach edad842a0b bump pointer: internal (inventory-key-control-chars released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:06:17 +02:00
539 changed files with 10054 additions and 1950 deletions
+2
View File
@@ -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
+11
View File
@@ -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
+17
View File
@@ -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
+44 -3
View File
@@ -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
+1 -1
View File
@@ -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"
+3
View File
@@ -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>
+2 -2
View File
@@ -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)) {
@@ -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();
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -49,6 +50,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
private static EmergencyReporter Reporter(FakeApiClient client)
=> new EmergencyReporter(client, "session", "tenant", "0.0.0", logger: null);
private static EmergencyReporter Reporter(FakeApiClient client, VirtualClock clock)
=> new EmergencyReporter(client, "session", "tenant", "0.0.0", logger: null, clock: clock);
private static VirtualClock Clock() => new VirtualClock(new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Session_unknown_report_is_not_suppressed_by_the_cooldown_of_an_earlier_report()
{
// Terminal report, sent at most once per process right before the termination — a
// routine report one minute earlier must not cost it. Every other type keeps the cooldown.
var clock = Clock();
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
await reporter.TrySendAsync(AgentErrorType.IngestFailed, "m", httpStatusCode: 503);
clock.Advance(TimeSpan.FromMinutes(1));
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, client.Reports[1].ErrorType);
// Control: a third report of another type inside the window is still held back.
clock.Advance(TimeSpan.FromMinutes(1));
await reporter.TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
{
var clock = Clock();
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
clock.Advance(TimeSpan.FromMinutes(1));
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
Assert.Equal(1, client.Calls);
}
[Fact]
public async Task Session_unknown_report_still_counts_against_the_per_session_maximum()
{
// Five delivered reports (ten minutes apart, distinct keys) exhaust the budget; the
// exemption waives only the cooldown, not the maximum.
var clock = Clock();
var client = new FakeApiClient();
for (var i = 0; i < 6; i++) client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
foreach (var status in new[] { 500, 502, 503, 504, 429 })
{
await reporter.TrySendAsync(AgentErrorType.IngestFailed, "m", httpStatusCode: status);
clock.Advance(TimeSpan.FromMinutes(10));
}
Assert.Equal(5, client.Calls);
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
Assert.Equal(5, client.Calls);
}
[Fact]
public async Task Failed_send_rolls_back_reservation_so_a_later_call_retries()
{
@@ -142,6 +211,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Session_unknown_report_carries_error_type_and_http_410()
{
var client = new FakeApiClient();
client.Results.Enqueue(true);
var reporter = Reporter(client);
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
var report = Assert.Single(client.Reports);
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, report.ErrorType);
Assert.Equal(410, report.HttpStatusCode);
}
[Fact]
public async Task Session_age_is_carried_in_the_report_and_absent_by_default()
{
@@ -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();
@@ -0,0 +1,170 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Host wiring for <c>TelemetryUploadOrchestrator.SessionGone</c>
/// (HTTP 410 on the current session's telemetry): exactly one
/// <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> emergency report carrying the
/// status, then a synthetic forced <c>terminate_session</c> tagged <c>origin=session_gone</c>
/// — and nothing at all once a shutdown is already in progress.
/// </summary>
public sealed class ServerControlPlaneSessionGoneTests
{
private sealed class RecordingEmergencyReporter : EmergencyReporter
{
private readonly List<string> _sequence;
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode)> Reports { get; } =
new List<(AgentErrorType, string, int?)>();
public RecordingEmergencyReporter(List<string> sequence)
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
{
_sequence = sequence;
}
public override Task TrySendAsync(
AgentErrorType errorType,
string message,
int? httpStatusCode = null,
long? sequenceNumber = null,
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null,
double? sessionAgeHours = null,
RegistrationFailureSummary? priorRegistrationFailure = null)
{
Reports.Add((errorType, message, httpStatusCode));
_sequence.Add("report");
return Task.CompletedTask;
}
}
private sealed class RecordingDispatcher : ServerActionDispatcher
{
private readonly List<string> _sequence;
public List<ServerAction> Dispatched { get; } = new List<ServerAction>();
public RecordingDispatcher(AgentConfiguration config, AgentLogger logger, InformationalEventPost post, List<string> sequence)
: base(
configuration: config,
logger: logger,
rotateConfigAsync: () => Task.FromResult(true),
uploadDiagnosticsAsync: _ => Task.FromResult<DiagnosticsUploadResult>(null!),
onTerminateRequested: _ => Task.CompletedTask,
post: post)
{
_sequence = sequence;
}
public override Task DispatchAsync(List<ServerAction> actions)
{
Dispatched.AddRange(actions);
_sequence.Add("dispatch");
return Task.CompletedTask;
}
}
private sealed class Rig : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public List<string> Sequence { get; } = new List<string>();
public RecordingEmergencyReporter Reporter { get; }
public RecordingDispatcher Dispatcher { get; }
public ManualResetEventSlim Shutdown { get; } = new ManualResetEventSlim(false);
public ManualResetEventSlim ShutdownComplete { get; } = new ManualResetEventSlim(false);
public Rig()
{
Logger = new AgentLogger(Path.Combine(Tmp.Path, "logs"), AgentLogLevel.Info);
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", ApiBaseUrl = "http://localhost" };
var post = new InformationalEventPost(
new FakeSignalIngressSink(),
new VirtualClock(new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc)));
Reporter = new RecordingEmergencyReporter(Sequence);
Dispatcher = new RecordingDispatcher(config, Logger, post, Sequence);
}
public void Handle(string reason = "session gone: http 410") =>
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleSessionGone(
reason, Reporter, Dispatcher, Shutdown, ShutdownComplete, Logger);
public void Dispose()
{
Shutdown.Dispose();
ShutdownComplete.Dispose();
Tmp.Dispose();
}
}
[Fact]
public void Reports_once_with_410_then_dispatches_forced_terminate_tagged_session_gone()
{
using var rig = new Rig();
rig.Handle();
Assert.Equal(new[] { "report", "dispatch" }, rig.Sequence);
var report = Assert.Single(rig.Reporter.Reports);
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, report.ErrorType);
Assert.Equal(410, report.HttpStatusCode);
Assert.Contains("410", report.Message);
var action = Assert.Single(rig.Dispatcher.Dispatched);
Assert.Equal(ServerActionTypes.TerminateSession, action.Type);
Assert.Contains("410", action.Reason);
Assert.NotNull(action.Params);
var parameters = action.Params!;
Assert.Equal("true", parameters["forceSelfDestruct"]);
Assert.Equal("0", parameters["gracePeriodSeconds"]);
Assert.Equal(TerminationOrigins.SessionGone, parameters["origin"]);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void Does_nothing_when_a_shutdown_is_already_in_progress(bool shutdownSet, bool shutdownCompleteSet)
{
using var rig = new Rig();
if (shutdownSet) rig.Shutdown.Set();
if (shutdownCompleteSet) rig.ShutdownComplete.Set();
rig.Handle();
Assert.Empty(rig.Reporter.Reports);
Assert.Empty(rig.Dispatcher.Dispatched);
}
[Fact]
public void ReadOrigin_returns_the_origin_param_or_null()
{
Assert.Equal(TerminationOrigins.SessionGone,
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
new Dictionary<string, string> { ["origin"] = TerminationOrigins.SessionGone }));
Assert.Equal(TerminationOrigins.KillSignal,
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
new Dictionary<string, string> { ["origin"] = TerminationOrigins.KillSignal, ["forceSelfDestruct"] = "true" }));
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
new Dictionary<string, string> { ["adminOutcome"] = "Failed" }));
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
new Dictionary<string, string> { ["origin"] = string.Empty }));
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(null!));
}
}
}
@@ -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);
}
@@ -310,6 +310,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
new EnrollmentTerminatedEventArgs(
reason, outcome, stage.ToString(), at ?? EndUtc);
/// <summary>
/// The args <c>ServerControlPlane.OnTerminateRequested</c> synthesises for a
/// <c>terminate_session</c> action tagged with <paramref name="origin"/>.
/// </summary>
private static EnrollmentTerminatedEventArgs ServerArgs(string origin) =>
new EnrollmentTerminatedEventArgs(
EnrollmentTerminationReason.DecisionTerminalStage,
EnrollmentTerminationOutcome.Failed,
SessionStage.Failed.ToString(),
EndUtc,
details: "Server-requested termination",
origin: origin);
[Fact]
public void Handle_writes_final_status_json_in_state_directory()
{
@@ -458,6 +471,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal(0, rig.CleanupService.Invocations);
}
// ============================================================= origin=session_gone (HTTP 410)
[Fact]
public void Handle_session_gone_skips_spool_drain_and_diagnostics_but_still_cleans_up()
{
// The backend no longer knows the session: every drain would only burn its budget on
// 410s and a diagnostics package would describe a deleted session. Marker, cleanup
// and the (forced) self-destruct still run.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
rig.SpoolDrainPeriodOverride = TimeSpan.FromSeconds(5);
var spoolPolls = 0;
rig.PendingItemCountAccessor = () => { spoolPolls++; return 5; }; // never drains — a poll would burn the budget
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: true);
rig.Build(cfg).Handle(sender: null!, ServerArgs(TerminationOrigins.SessionGone));
Assert.Equal(0, spoolPolls);
Assert.Equal(0, rig.DiagnosticsUploads);
Assert.DoesNotContain(Constants.EventTypes.DiagnosticsCollecting, rig.EmittedEventTypes);
Assert.Contains(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes);
Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
Assert.Equal(1, rig.CleanupService.Invocations);
Assert.Equal(1, rig.ShutdownSignalled);
}
[Fact]
public void Handle_kill_signal_origin_still_drains_spool_and_uploads_diagnostics()
{
// The administrator kill signal keeps its full sequence — only session_gone skips.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
rig.SpoolDrainPeriodOverride = TimeSpan.FromSeconds(5);
var spoolPolls = 0;
rig.PendingItemCountAccessor = () => { spoolPolls++; return 0; };
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: true);
rig.Build(cfg).Handle(sender: null!, ServerArgs(TerminationOrigins.KillSignal));
Assert.True(spoolPolls > 0);
Assert.Equal(1, rig.DiagnosticsUploads);
Assert.Contains(Constants.EventTypes.DiagnosticsCollecting, rig.EmittedEventTypes);
Assert.Equal(1, rig.CleanupService.Invocations);
Assert.Equal(1, rig.ShutdownSignalled);
}
[Fact]
public void Handle_signals_shutdown_exactly_once_even_on_error()
{
@@ -275,9 +275,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.False(result.Success);
Assert.False(result.IsTransient);
Assert.False(result.IsSessionGone);
Assert.Contains(((int)status).ToString(), result.ErrorReason);
}
[Fact]
public async Task Gone_410_maps_to_SessionGone()
{
// 410 = the backend no longer knows this session. Not transient (no retry), not auth,
// not poison, not a size problem — a distinct terminal kind the drain reacts to.
using var rig = new Rig();
rig.Handler.QueueStatus(HttpStatusCode.Gone, body: "session unknown");
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.False(result.Success);
Assert.True(result.IsSessionGone);
Assert.False(result.IsTransient);
Assert.False(result.IsAuthFailure);
Assert.False(result.IsPoison);
Assert.False(result.RequiresSplit);
Assert.Contains("410", result.ErrorReason);
}
[Fact]
public async Task Gone_410_with_poison_body_is_still_SessionGone_never_poison()
{
// A poison body on 410 must not be honoured: dropping items + emitting
// telemetry_upload_poisoned would only produce the next 410 (emit/reject loop).
using var rig = new Rig();
rig.Handler.QueueStatus(HttpStatusCode.Gone, body: "{\"poison\":true,\"rejectedRowKeys\":[\"rk1\"],\"reason\":\"x\"}");
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.True(result.IsSessionGone);
Assert.False(result.IsPoison);
Assert.Null(result.PoisonRowKeys);
}
[Theory]
[InlineData(HttpStatusCode.RequestTimeout)] // 408
[InlineData((HttpStatusCode)429)] // TooManyRequests
@@ -68,6 +68,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
return this;
}
public FakeBackendTelemetryUploader QueueSessionGone(string reason = "session gone: http 410")
{
_script.Enqueue(_ => UploadResult.SessionGone(reason));
return this;
}
public FakeBackendTelemetryUploader QueueThrow(Exception ex)
{
_script.Enqueue(_ => throw ex);
@@ -10,10 +10,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class TelemetrySpoolTests
{
private static TelemetryItemDraft EventDraft(string rowKey = "row-1") =>
private const string OwnKey = "tenant_session";
private const string ForeignKey = "tenant_old-session";
private static TelemetryItemDraft EventDraft(string rowKey = "row-1") => EventDraft(rowKey, OwnKey);
private static TelemetryItemDraft EventDraft(string rowKey, string partitionKey) =>
new TelemetryItemDraft(
kind: TelemetryItemKind.Event,
partitionKey: "tenant_session",
partitionKey: partitionKey,
rowKey: rowKey,
payloadJson: "{\"EventType\":\"agent_started\"}",
isSessionScoped: true);
@@ -195,6 +200,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Equal(1, next.TelemetryItemId);
}
// ============================================================ Startup partition-key filter
[Fact]
public void Ctor_drops_leading_pending_items_of_another_session_and_persists_cursor()
{
// Same spool directory, new session id: the previous session's unsent items would only
// draw a 410 that must not end the new session. They are dropped on startup; the own
// item behind them stays pending.
using var tmp = new TempDirectory();
var s1 = new TelemetrySpool(tmp.Path, Clock());
s1.Enqueue(EventDraft("old-a", ForeignKey));
s1.Enqueue(EventDraft("old-b", ForeignKey));
s1.Enqueue(EventDraft("own", OwnKey));
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
Assert.Equal(1, s2.LastUploadedItemId);
Assert.Equal(2, s2.LastAssignedItemId);
Assert.Equal(1, s2.PendingItemCount);
Assert.Equal("own", Assert.Single(s2.Peek(10)).RowKey);
// Cursor persisted: a later start without the filter sees the same tail with the same bytes.
var s3 = new TelemetrySpool(tmp.Path, Clock());
Assert.Equal(1, s3.LastUploadedItemId);
Assert.Equal(1, s3.PendingItemCount);
Assert.Equal(s3.PendingBytes, s2.PendingBytes);
var next = s2.Enqueue(EventDraft("next", OwnKey));
Assert.Equal(3, next.TelemetryItemId);
}
[Fact]
public void Ctor_drops_nothing_when_a_foreign_item_follows_an_own_item()
{
// Impossible by construction (one process = one session); if it happens the order is
// not trustworthy, so the filter keeps everything instead of guessing.
using var tmp = new TempDirectory();
var s1 = new TelemetrySpool(tmp.Path, Clock());
s1.Enqueue(EventDraft("old-a", ForeignKey));
s1.Enqueue(EventDraft("own", OwnKey));
s1.Enqueue(EventDraft("old-b", ForeignKey));
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
Assert.Equal(-1, s2.LastUploadedItemId);
Assert.Equal(3, s2.PendingItemCount);
Assert.Equal(s1.PendingBytes, s2.PendingBytes);
}
[Fact]
public void Ctor_keeps_pending_items_that_carry_the_expected_key()
{
// WhiteGlove Part 2 reuses the Part-1 session id: its leftovers carry the expected key.
using var tmp = new TempDirectory();
var s1 = new TelemetrySpool(tmp.Path, Clock());
s1.Enqueue(EventDraft("a", OwnKey));
s1.Enqueue(EventDraft("b", OwnKey));
s1.Enqueue(EventDraft("c", OwnKey));
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
Assert.Equal(-1, s2.LastUploadedItemId);
Assert.Equal(3, s2.PendingItemCount);
Assert.Equal(s1.PendingBytes, s2.PendingBytes);
}
[Fact]
public void Ctor_without_expected_key_keeps_foreign_items()
{
using var tmp = new TempDirectory();
var s1 = new TelemetrySpool(tmp.Path, Clock());
s1.Enqueue(EventDraft("old-a", ForeignKey));
s1.Enqueue(EventDraft("old-b", ForeignKey));
var s2 = new TelemetrySpool(tmp.Path, Clock());
Assert.Equal(-1, s2.LastUploadedItemId);
Assert.Equal(2, s2.PendingItemCount);
}
[Fact]
public void Enqueue_with_RequiresImmediateFlush_raises_ImmediateFlushRequested()
{
@@ -10,10 +10,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class TelemetryUploadOrchestratorTests
{
private static TelemetryItemDraft Draft(string rowKey) =>
private const string OwnKey = "tenant_session";
private static TelemetryItemDraft Draft(string rowKey) => Draft(rowKey, OwnKey);
private static TelemetryItemDraft Draft(string rowKey, string partitionKey) =>
new TelemetryItemDraft(
kind: TelemetryItemKind.Event,
partitionKey: "tenant_session",
partitionKey: partitionKey,
rowKey: rowKey,
payloadJson: $"{{\"rk\":\"{rowKey}\"}}",
isSessionScoped: true);
@@ -157,6 +161,118 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Equal(1, blockedCount);
}
// ============================================================ HTTP 410 — session gone
[Fact]
public async Task SessionGone_for_current_session_stops_drain_retains_cursor_and_raises_once()
{
using var tmp = new TempDirectory();
var spool = new TelemetrySpool(tmp.Path, Clock());
spool.Enqueue(Draft("a"));
spool.Enqueue(Draft("b"));
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueSessionGone();
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
var gone = new System.Collections.Generic.List<string>();
int blockedCount = 0;
PoisonReport? poison = null;
sut.SessionGone += (_, reason) => gone.Add(reason);
sut.UploadBlocked += (_, __) => blockedCount++;
sut.BatchPoisoned += (_, p) => poison = p;
var first = await sut.DrainAllAsync();
Assert.False(first.Success);
Assert.Equal(1, first.FailedBatches);
Assert.Equal(0, first.UploadedItems);
Assert.Equal(1, uploader.CallCount); // no retries — nothing can make a 410 succeed
Assert.Equal(-1, sut.LastUploadedItemId); // retained — items stay on disk for the diag ZIP
Assert.Single(gone);
Assert.Contains("410", gone[0]);
Assert.Equal(0, blockedCount); // not a "blocked" pipeline
Assert.Null(poison);
// One-shot: the next drain hits the 410 again but does not raise again.
var second = await sut.DrainAllAsync();
Assert.False(second.Success);
Assert.Equal(2, uploader.CallCount);
Assert.Equal(-1, sut.LastUploadedItemId);
Assert.Single(gone);
Assert.Equal(0, blockedCount);
}
[Fact]
public async Task SessionGone_for_leading_foreign_items_drops_only_them_and_continues()
{
// Leftovers of a previous session id at the head of the spool (defence in depth — the
// spool filters these at startup). A 410 for them must not end the current session:
// drop exactly the leading foreign run, then carry on with the current session's items.
using var tmp = new TempDirectory();
var spool = new TelemetrySpool(tmp.Path, Clock());
spool.Enqueue(Draft("old-a", "tenant_old-session"));
spool.Enqueue(Draft("old-b", "tenant_old-session"));
spool.Enqueue(Draft("new", OwnKey));
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueOk();
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
int goneCount = 0, blockedCount = 0;
sut.SessionGone += (_, __) => goneCount++;
sut.UploadBlocked += (_, __) => blockedCount++;
var result = await sut.DrainAllAsync();
Assert.True(result.Success);
Assert.Equal(0, result.FailedBatches);
Assert.Equal(1, result.UploadedItems);
Assert.Equal(2, uploader.CallCount);
Assert.Equal(3, uploader.Received[0].Count); // first batch carried all three
Assert.Equal("new", Assert.Single(uploader.Received[1]).RowKey); // retry carried only the own item
Assert.Equal(2, sut.LastUploadedItemId);
Assert.Equal(0, goneCount);
Assert.Equal(0, blockedCount);
}
[Fact]
public async Task SessionGone_is_not_latched_before_a_handler_is_subscribed()
{
// The host subscribes after EnrollmentOrchestrator.Start; a 410 on the very first drain
// must still reach it on the next drain instead of being swallowed by the one-shot guard.
using var tmp = new TempDirectory();
var spool = new TelemetrySpool(tmp.Path, Clock());
spool.Enqueue(Draft("only"));
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueSessionGone();
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
await sut.DrainAllAsync(); // nobody listening yet
int goneCount = 0;
sut.SessionGone += (_, __) => goneCount++;
await sut.DrainAllAsync();
Assert.Equal(1, goneCount);
Assert.Equal(-1, sut.LastUploadedItemId);
}
[Fact]
public async Task SessionGone_without_a_current_key_treats_every_batch_as_own()
{
using var tmp = new TempDirectory();
var spool = new TelemetrySpool(tmp.Path, Clock());
spool.Enqueue(Draft("x", "tenant_whatever"));
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone();
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock());
int goneCount = 0;
sut.SessionGone += (_, __) => goneCount++;
var result = await sut.DrainAllAsync();
Assert.False(result.Success);
Assert.Equal(1, goneCount);
Assert.Equal(-1, sut.LastUploadedItemId);
}
[Fact]
public async Task TooLarge_splits_batch_and_uploads_everything()
{
@@ -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");
@@ -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>
@@ -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:
@@ -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,
@@ -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)
@@ -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) };
@@ -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
@@ -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)
@@ -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
@@ -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();
@@ -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>
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
@@ -14,6 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
@@ -32,6 +34,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private readonly string _tenantId;
private readonly string _agentVersion;
private readonly AgentLogger _logger;
private readonly IClock _clock;
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
private readonly object _antiFloodLock = new object();
@@ -43,13 +46,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
string sessionId,
string tenantId,
string agentVersion,
AgentLogger logger)
AgentLogger logger,
IClock clock = null)
{
_apiClient = apiClient;
_sessionId = sessionId;
_tenantId = tenantId;
_agentVersion = agentVersion;
_logger = logger;
_clock = clock ?? SystemClock.Instance;
}
/// <summary>
@@ -100,6 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
// All anti-flood checks are protected by a lock to prevent race conditions when
// TrySendAsync is called concurrently (fire-and-forget from multiple threads).
var key = $"{errorType}:{httpStatusCode}";
var now = _clock.UtcNow;
int currentReportCount;
DateTime? previousLastReportTime;
@@ -117,8 +123,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return;
}
if (_lastReportTime != null &&
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
// The session-unknown report is terminal and sent at most once per process (the
// termination follows immediately): a routine report minutes earlier must not cost
// the last word about the session. Only the cooldown is waived; key dedup and the
// per-session maximum still apply.
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
if (!exemptFromCooldown && _lastReportTime != null &&
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
return;
@@ -127,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
previousLastReportTime = _lastReportTime;
_reportedKeys.Add(key);
_reportCount++;
_lastReportTime = DateTime.UtcNow;
_lastReportTime = now;
currentReportCount = _reportCount;
}
@@ -145,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
SessionAgeHours = sessionAgeHours,
PriorRegistrationFailure = priorRegistrationFailure,
AgentVersion = _agentVersion,
Timestamp = DateTime.UtcNow
Timestamp = now
};
var delivered = false;
@@ -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
@@ -371,8 +371,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// 3) Telemetry-Transport. Batch size flows from AgentConfiguration.MaxBatchSize
// via Program.cs (P1 fix: previously the remote-config knob was merged but
// never applied, so tenants saw drainInterval/MaxBatchSize have no effect).
_spool = new TelemetrySpool(_transportDirectory, _clock, _logger);
_transport = new TelemetryUploadOrchestrator(_spool, _uploader, _clock, batchSize: _uploadBatchSize);
// The partition key names the registered session: the spool drops leftovers of a
// previous session id on startup, and the orchestrator tells a 410 for this session
// (SessionGone → host terminates) from a 410 for such leftovers (dropped).
var partitionKey = TelemetryPartitionKey.ForSession(_tenantId, _sessionId);
_spool = new TelemetrySpool(_transportDirectory, _clock, _logger, expectedPartitionKey: partitionKey);
_transport = new TelemetryUploadOrchestrator(
_spool, _uploader, _clock, batchSize: _uploadBatchSize, currentPartitionKey: partitionKey, logger: _logger);
// TRACE-H1 — surface skipped oversized items + blocked-pipeline conditions as timeline
// events. Raised after the drain guard releases, so posting back through the ingress
@@ -73,13 +73,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public string? StopReason { get; }
/// <summary>
/// The <c>origin</c> param of a server-requested <c>terminate_session</c> action
/// (<see cref="TerminationOrigins"/>); null for every termination the agent decided
/// itself. <see cref="TerminationOrigins.SessionGone"/> makes the termination handler
/// skip the spool drain and the diagnostics upload — the backend no longer knows the
/// session, so both could only fail.
/// </summary>
public string? Origin { get; }
public EnrollmentTerminatedEventArgs(
EnrollmentTerminationReason reason,
EnrollmentTerminationOutcome outcome,
string? stageName,
DateTime terminatedAtUtc,
string? details = null,
string? stopReason = null)
string? stopReason = null,
string? origin = null)
{
Reason = reason;
Outcome = outcome;
@@ -87,6 +97,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
TerminatedAtUtc = terminatedAtUtc;
Details = details;
StopReason = stopReason;
Origin = origin;
}
}
/// <summary>
/// Values of the <c>origin</c> param on a synthesised <c>terminate_session</c>
/// <c>ServerAction</c>, carried into <see cref="EnrollmentTerminatedEventArgs.Origin"/>.
/// </summary>
public static class TerminationOrigins
{
/// <summary>Administrator kill signal (<c>DeviceKillSignal</c> on a 2xx telemetry response).</summary>
public const string KillSignal = "kill_signal";
/// <summary>The backend answered HTTP 410 for the current session's telemetry.</summary>
public const string SessionGone = "session_gone";
}
}
@@ -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()
@@ -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);
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
_sequenceCounter = sequenceCounter ?? throw new ArgumentNullException(nameof(sequenceCounter));
_sessionId = sessionId;
_tenantId = tenantId;
_partitionKey = $"{tenantId}_{sessionId}";
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
_traceEventsEnabled = traceEventsEnabled;
}
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_partitionKey = $"{tenantId}_{sessionId}";
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
}
/// <summary>
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_partitionKey = $"{tenantId}_{sessionId}";
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
}
/// <summary>
@@ -306,7 +306,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
});
DelayLateEventGrace();
DrainSpool();
DrainSpool(args);
// Option 2 (WG Part 1 graceful-exit hardening, 2026-04-30): write the
// clean-exit marker BEFORE _signalShutdown returns control to the main
@@ -329,7 +329,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
RunUploadDiagnosticsWithEvents(args);
WriteEnrollmentCompleteMarker(args);
RunStandaloneRebootIfRequested();
RunStandaloneRebootIfRequested(args);
RunSelfDestructIfAppropriate(args);
// Option 2 (same hardening) — covers the standard Completed / Failed terminal
// path too. Cleanup PowerShell does not touch this marker, so writing it before
@@ -979,6 +979,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// </param>
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
{
if (IsSessionGone(args))
{
// The backend answered 410 for this session: the package would describe a session
// that no longer exists, and its diagnostics_* events could never be accepted.
_logger.Info("EnrollmentTerminationHandler: diagnostics upload skipped — backend no longer knows this session.");
return;
}
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
@@ -1124,7 +1132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// but enables <c>RebootOnComplete</c>, the agent's final act is <c>shutdown.exe /r</c>
/// with the configured delay, giving the user a visible countdown.
/// </summary>
private void RunStandaloneRebootIfRequested()
private void RunStandaloneRebootIfRequested(EnrollmentTerminatedEventArgs args)
{
if (_configuration.SelfDestructOnComplete) return;
if (!_configuration.RebootOnComplete) return;
@@ -1148,7 +1156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
ImmediateUpload = true,
});
DrainSpool();
DrainSpool(args);
try
{
@@ -1185,7 +1193,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// just above. The WG-Part-1 and standalone-reboot paths already drain first; this
// closes the gap on the self-destruct path. Bounded + best-effort (never blocks
// termination on a wedged backend).
DrainSpool();
DrainSpool(args);
var service = _cleanupServiceFactory();
service.ExecuteSelfDestruct();
@@ -1318,8 +1326,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
private void DrainSpool()
/// <summary>
/// <see cref="TerminationOrigins.SessionGone"/>: the backend no longer knows the session.
/// Every drain would only collect more 410s against a bounded budget, and a diagnostics
/// package would describe a deleted session — both are skipped; marker, cleanup and
/// self-destruct run as usual.
/// </summary>
private static bool IsSessionGone(EnrollmentTerminatedEventArgs args) =>
string.Equals(args.Origin, TerminationOrigins.SessionGone, StringComparison.Ordinal);
private void DrainSpool(EnrollmentTerminatedEventArgs args)
{
if (IsSessionGone(args))
{
_logger.Info("EnrollmentTerminationHandler: spool drain skipped — backend no longer knows this session.");
return;
}
// Block briefly so pending events can land before the next destructive step
// (shutdown.exe, self-destruct). Two phases share the same bounded budget:
//
@@ -165,6 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
_logger?.Warning($"BackendTelemetryUploader: ingest TRANSIENT (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
}
else if (result.IsSessionGone)
{
_logger?.Error($"BackendTelemetryUploader: ingest rejected — backend no longer knows this session (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
}
else
{
_logger?.Error($"BackendTelemetryUploader: ingest PERMANENT fail (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
@@ -208,6 +212,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
case HttpStatusCode.RequestEntityTooLarge: // 413 — the data is fine, only the batch
return UploadResult.TooLarge(shortReason); // size is wrong → split & retry (P1).
case HttpStatusCode.Gone: // 410 — the backend no longer knows this session.
// Decided BEFORE the poison-body check: a poison body on 410 would make the
// drain drop items and emit telemetry_upload_poisoned, which the backend
// rejects with 410 again — an emit/reject loop for a session that is over.
return UploadResult.SessionGone($"session gone: {shortReason}");
}
if (statusCode >= 500 && statusCode <= 599) return UploadResult.Transient(shortReason);
@@ -0,0 +1,14 @@
#nullable enable
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
/// <summary>
/// The one composition of a session's Azure-Table partition key. The three emitters stamp
/// it on every <see cref="TelemetryItem"/>; the spool and the upload orchestrator compare
/// against it to tell the current session's items from leftovers of a previous session id.
/// A drift between producer and comparer would silently drop or retain the wrong items.
/// </summary>
public static class TelemetryPartitionKey
{
public static string ForSession(string tenantId, string sessionId) => $"{tenantId}_{sessionId}";
}
}
@@ -74,7 +74,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
public int ByteLength { get; }
}
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
/// <param name="expectedPartitionKey">
/// Partition key of the session this process serves (<see cref="TelemetryPartitionKey.ForSession"/>).
/// When set, pending items of another session id found at the head of the spool are dropped
/// on startup — see <see cref="DropLeadingForeignPending"/>. Null disables the filter.
/// </param>
public TelemetrySpool(
string directoryPath,
IClock clock,
Logging.AgentLogger? logger = null,
string? expectedPartitionKey = null)
{
if (string.IsNullOrEmpty(directoryPath))
{
@@ -96,6 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
// count as "pending" and need to be rehydrated into _pending.
_lastUploadedItemId = _cursor.Load();
_lastAssignedItemId = ScanSpoolAndRehydratePending();
DropLeadingForeignPending(expectedPartitionKey);
_peakPendingCount = _pending.Count;
}
@@ -312,5 +322,68 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
}
return highest;
}
/// <summary>
/// Startup filter: drops the LEADING run of pending items whose partition key is not
/// <paramref name="expectedPartitionKey"/>, advancing and persisting the cursor past them.
/// Such items are leftovers of a previous session id in the same spool directory; the
/// backend answers 410 for them, and that 410 must never end the session this process
/// serves. One process serves one session, so a foreign item AFTER an own item cannot
/// occur — if it does, the order is not trustworthy and nothing is dropped. WhiteGlove
/// Part 2 reuses the Part-1 session id, so its leftovers carry the expected key and stay.
/// Called once from the ctor, after rehydration.
/// </summary>
private void DropLeadingForeignPending(string? expectedPartitionKey)
{
if (string.IsNullOrEmpty(expectedPartitionKey) || _pending.Count == 0) return;
var leadingForeign = 0;
long lastForeignId = -1;
var seenOwn = false;
foreach (var entry in _pending)
{
var isForeign = !string.Equals(entry.Item.PartitionKey, expectedPartitionKey, StringComparison.Ordinal);
if (!isForeign)
{
seenOwn = true;
continue;
}
if (!seenOwn)
{
leadingForeign++;
lastForeignId = entry.Item.TelemetryItemId;
continue;
}
_logger?.Error(
$"TelemetrySpool: pending itemId={entry.Item.TelemetryItemId} carries a foreign partition key after items of the current session — spool order is inconsistent; keeping all {_pending.Count} pending item(s).");
return;
}
if (leadingForeign == 0) return;
try
{
_cursor.Save(lastForeignId);
}
catch (Exception ex)
{
// Keep the items rather than risk a cursor that disagrees with disk; the drain
// loop drops the same leading run on its first 410 (defence in depth).
_logger?.Warning($"TelemetrySpool: could not persist the cursor past {leadingForeign} foreign pending item(s): {ex.Message}");
return;
}
_lastUploadedItemId = lastForeignId;
for (var i = 0; i < leadingForeign; i++)
{
_pendingBytes -= _pending.First!.Value.ByteLength;
_pending.RemoveFirst();
}
_logger?.Warning(
$"TelemetrySpool: dropped {leadingForeign} pending item(s) of a previous session id (cursor advanced to {lastForeignId}, {_pending.Count} pending item(s) of the current session kept).");
}
}
}
@@ -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;
@@ -45,22 +44,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
// telemetry_upload_blocked exactly once per process rather than on every 30 s drain.
private bool _uploadBlockedReported;
// Partition key of the session this process serves; null = every batch counts as own.
private readonly string? _currentPartitionKey;
private readonly Logging.AgentLogger? _logger;
// One-shot guard for SessionGone. Latched only when a handler is subscribed: the host
// wires the handler after EnrollmentOrchestrator.Start, and a 410 on the very first
// drain must not be swallowed before anyone listens.
private bool _sessionGoneReported;
/// <param name="currentPartitionKey">
/// <see cref="TelemetryPartitionKey.ForSession"/> of the registered session. A 410 on a
/// batch that starts with this key ends the session (<see cref="SessionGone"/>); a 410 on
/// a batch that starts with any other key only drops those leftover items.
/// </param>
public TelemetryUploadOrchestrator(
ITelemetrySpool spool,
IBackendTelemetryUploader uploader,
IClock clock,
int batchSize = 100,
IReadOnlyList<TimeSpan>? retryBackoffs = null)
IReadOnlyList<TimeSpan>? retryBackoffs = null,
string? currentPartitionKey = null,
Logging.AgentLogger? logger = null)
{
_spool = spool ?? throw new ArgumentNullException(nameof(spool));
_uploader = uploader ?? throw new ArgumentNullException(nameof(uploader));
_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;
_currentPartitionKey = currentPartitionKey;
_logger = logger;
}
/// <summary>
@@ -97,6 +113,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// </summary>
public event EventHandler<string>? UploadBlocked;
/// <summary>
/// Raised once per process (after the drain guard releases) when the backend answered
/// HTTP 410 for a batch of the CURRENT session: the session is over server-side (never
/// registered, deleted, or being deleted) and no retry can succeed. The batch is retained
/// (cursor stays) and the drain stops; the host reports once over the emergency channel
/// and terminates the agent with self-destruct. Carries the backend error reason.
/// </summary>
public event EventHandler<string>? SessionGone;
/// <summary>
/// <c>true</c> when the last successful upload returned <see cref="UploadResult.DeviceBlocked"/>
/// and the paused-until window has not expired. Drain is a no-op in this state.
@@ -124,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
List<UploadResult>? pendingSignals = null;
List<PoisonReport>? pendingPoison = null;
string? pendingBlockedReason = null;
string? pendingSessionGoneReason = null;
DrainResult result;
try
@@ -247,6 +273,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
new PoisonReport(1, oversizeId, "oversize: " + outcome.ErrorReason, PoisonKind.Oversize));
break;
}
else if (outcome.IsSessionGone)
{
// HTTP 410 — the backend no longer knows the session these items belong to.
var foreignRun = CountLeadingForeignItems(batch);
if (foreignRun > 0)
{
// Leftovers of a previous session id (the spool drops these at startup;
// this is defence in depth). Drop only that leading run and carry on: a
// legitimate new session must never be ended because of old items.
var throughId = batch[foreignRun - 1].TelemetryItemId;
_spool.MarkUploaded(throughId);
_logger?.Warning(
$"TelemetryUploadOrchestrator: dropped {foreignRun} item(s) of a previous session id the backend rejected with 410 (cursor advanced to {throughId}).");
continue;
}
// The current session is over server-side. RETAIN (cursor stays, the items
// remain on disk for the diagnostics ZIP), stop this drain, and surface it
// once — not as telemetry_upload_blocked, which would only queue another
// item behind a batch that can never be accepted.
failedBatches++;
lastError = outcome.ErrorReason;
if (!_sessionGoneReported && SessionGone != null)
{
_sessionGoneReported = true;
pendingSessionGoneReason = outcome.ErrorReason;
}
break;
}
else
{
// P1 — RETAIN. Transient (retry next drain), auth-permanent (uploader already
@@ -301,9 +356,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
catch { /* handler must not abort the drain result */ }
}
if (pendingSessionGoneReason != null)
{
try { SessionGone?.Invoke(this, pendingSessionGoneReason); }
catch { /* handler must not abort the drain result */ }
}
return result;
}
/// <summary>
/// Number of items at the head of <paramref name="batch"/> whose partition key is not the
/// current session's. 0 when no current key is configured (every batch counts as own).
/// </summary>
private int CountLeadingForeignItems(IReadOnlyList<TelemetryItem> batch)
{
if (_currentPartitionKey == null) return 0;
var count = 0;
while (count < batch.Count
&& !string.Equals(batch[count].PartitionKey, _currentPartitionKey, StringComparison.Ordinal))
{
count++;
}
return count;
}
private static bool CarriesSignal(UploadResult outcome) =>
outcome.DeviceBlocked
|| outcome.DeviceKillSignal
@@ -30,7 +30,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
bool isAuthFailure = false,
bool requiresSplit = false,
bool isPoison = false,
IReadOnlyList<string>? poisonRowKeys = null)
IReadOnlyList<string>? poisonRowKeys = null,
bool isSessionGone = false)
{
Success = success;
ErrorReason = errorReason;
@@ -44,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
RequiresSplit = requiresSplit;
IsPoison = isPoison;
PoisonRowKeys = poisonRowKeys;
IsSessionGone = isSessionGone;
}
public bool Success { get; }
@@ -81,6 +83,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// <summary>The RowKeys the backend named as poison (item-level). Non-empty when <see cref="IsPoison"/>.</summary>
public IReadOnlyList<string>? PoisonRowKeys { get; }
/// <summary>
/// HTTP 410: the backend no longer knows the session the batch belongs to (never
/// registered, deleted, or being deleted). Not transient, not auth, not poison — retrying
/// can never succeed. The orchestrator stops the drain (cursor stays) and raises
/// <see cref="TelemetryUploadOrchestrator.SessionGone"/> when the batch belongs to the
/// current session, or drops the batch's leading items when they are leftovers of a
/// previous session id.
/// </summary>
public bool IsSessionGone { get; }
/// <summary>
/// Non-terminal quarantine signalled by the backend. The agent should stop draining
/// uploads until <see cref="UnblockAt"/> (if set) elapses — the session stays alive so
@@ -165,5 +177,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: false,
deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null,
isPoison: true, poisonRowKeys: rejectedRowKeys ?? throw new ArgumentNullException(nameof(rejectedRowKeys)));
/// <summary>
/// HTTP 410 — the backend no longer knows the session. Flagged <see cref="IsSessionGone"/>;
/// never retried, never split, never treated as poison.
/// </summary>
public static UploadResult SessionGone(string reason) =>
new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: false,
deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null,
isSessionGone: true);
}
}
@@ -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
@@ -439,6 +439,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
lifecyclePost,
() => terminationHandler,
agentConfig,
auth.EmergencyReporter,
shutdown,
shutdownComplete,
logger);
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Shared;
@@ -103,6 +105,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// dispatcher because these are real actions, not admin-button echoes.</item>
/// <item><b>DeviceBlocked</b> — non-terminal upload pause. Transport already reacted in
/// <c>TelemetryUploadOrchestrator.ApplyControlSignals</c>; here we only log.</item>
/// <item><b>SessionGone</b> (<see cref="TelemetryUploadOrchestrator.SessionGone"/>, HTTP 410
/// on the current session's telemetry) — one emergency report, then the kill-signal
/// termination path tagged <c>origin=session_gone</c>. See <see cref="HandleSessionGone"/>.</item>
/// </list>
/// <para>
/// <b>Shutdown race guard</b>: if the agent has already finished its own termination
@@ -117,9 +122,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
InformationalEventPost post,
Func<EnrollmentTerminationHandler> terminationHandlerAccessor,
AgentConfiguration agentConfig,
EmergencyReporter emergencyReporter,
ManualResetEventSlim shutdown,
ManualResetEventSlim shutdownComplete,
AgentLogger logger)
{
orchestrator.Transport.SessionGone += (sender, reason) =>
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
{
if (shutdownComplete.IsSet)
@@ -140,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{
{ "forceSelfDestruct", "true" },
{ "gracePeriodSeconds", "0" },
{ "origin", "kill_signal" },
{ "origin", TerminationOrigins.KillSignal },
},
};
try { dispatcher.DispatchAsync(new List<ServerAction> { killAction }).GetAwaiter().GetResult(); }
@@ -181,6 +191,76 @@ namespace AutopilotMonitor.Agent.V2.Runtime
};
}
// Bounded blocking send for the one session-gone report: the terminate that follows ends
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
private const int SessionGoneReportAttempts = 2;
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
/// <summary>
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
/// report over the emergency channel — the telemetry channel is exactly what was refused,
/// and the backend accepts this report for unknown sessions. (b) Terminate through the same
/// synthesised <c>terminate_session</c> path as the administrator kill signal (forced
/// self-destruct, no grace), tagged <see cref="TerminationOrigins.SessionGone"/> so the
/// termination handler skips the spool drain and the diagnostics upload. A shutdown that is
/// already in progress owns the outcome — then nothing happens here.
/// </summary>
internal static void HandleSessionGone(
string reason,
EmergencyReporter emergencyReporter,
ServerActionDispatcher dispatcher,
ManualResetEventSlim shutdown,
ManualResetEventSlim shutdownComplete,
AgentLogger logger)
{
if (shutdownComplete.IsSet || shutdown.IsSet)
{
logger.Debug("ServerControlPlane: session gone (HTTP 410) while shutdown is already in progress — ignoring.");
return;
}
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
if (emergencyReporter != null)
{
try
{
emergencyReporter
.TrySendAsync(
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: SessionGoneReportAttempts,
perAttemptTimeout: SessionGoneReportTimeout,
retryDelay: SessionGoneReportRetryDelay)
.Wait(SessionGoneReportBudget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
var terminateAction = new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = "Backend no longer knows this session (HTTP 410)",
QueuedAt = DateTime.UtcNow,
Params = new Dictionary<string, string>
{
{ "forceSelfDestruct", "true" },
{ "gracePeriodSeconds", "0" },
{ "origin", TerminationOrigins.SessionGone },
},
};
try { dispatcher.DispatchAsync(new List<ServerAction> { terminateAction }).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Error("ServerActionDispatcher.DispatchAsync (session gone) threw during server-response wiring.", ex); }
}
/// <summary>
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
@@ -281,6 +361,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
: EnrollmentTerminationOutcome.Failed;
}
/// <summary>
/// The <c>origin</c> param of a synthesised <c>terminate_session</c> action
/// (<see cref="TerminationOrigins"/>), or null when absent — it travels into
/// <see cref="EnrollmentTerminatedEventArgs.Origin"/>.
/// </summary>
internal static string ReadOrigin(IReadOnlyDictionary<string, string> parameters)
{
if (parameters == null) return null;
return parameters.TryGetValue("origin", out var origin) && !string.IsNullOrEmpty(origin) ? origin : null;
}
// ============================================================ Private terminate-callback
/// <summary>
@@ -326,8 +417,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// before, masquerading every admin override as an error in SummaryDialog +
// firing spurious diagnostics uploads).
var mappedOutcome = MapAdminOutcome(action?.Params);
var origin = ReadOrigin(action?.Params);
logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}) — invoking termination handler.");
logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}, origin={origin ?? "none"}) — invoking termination handler.");
// Synthesise a Terminated event as if the kernel fired it.
terminationHandler.Handle(
sender: null,
@@ -336,7 +428,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
outcome: mappedOutcome,
stageName: orchestrator.CurrentState?.Stage.ToString(),
terminatedAtUtc: DateTime.UtcNow,
details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}"));
details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}",
origin: origin));
// Plan §6.2 synchronous-shutdown — block the ingest dispatcher thread until the
// main-thread cleanup (orchestrator.Stop, client disposal) has fully run. This
@@ -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)
{
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Analyze;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The two queue handlers derive rows for a session (rule results, reports, inventory counters,
/// CveIndex). For a session the backend does not know, or one a cascade is tearing down, they
/// must stop before the first read of session data — nothing derived may ever outlive the
/// session's cascade. Both handlers are driven up to that gate with every later dependency
/// unavailable, so a regression that moves the gate shows up as a null dereference or an
/// unexpected mock call.
/// </summary>
public class DerivedWriterLivenessTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private static SessionDeletionGuard GuardFor(TableEntity? row)
{
var reader = new Mock<ISessionDeletionInventoryReader>();
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
return new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance);
}
private static Mock<AdminConfigurationService> AdminConfigWithCorrelationEnabled()
{
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
adminConfig.Setup(a => a.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { VulnerabilityCorrelationEnabled = true });
return adminConfig;
}
public static IEnumerable<object?[]> NotLiveRows()
{
yield return new object?[] { null };
yield return new object?[] { new TableEntity(TenantId, SessionId) { ["DeletionState"] = SessionDeletionState.Running } };
}
[Theory]
[MemberData(nameof(NotLiveRows))]
public async Task VulnerabilityCorrelate_skips_a_missing_or_locked_session_before_loading_inventory(TableEntity? row)
{
var inventoryLoader = new Mock<IVulnerabilityInventoryLoader>(MockBehavior.Strict);
var vulnRepo = new Mock<IVulnerabilityRepository>(MockBehavior.Strict);
var handler = new VulnerabilityCorrelateHandler(
AdminConfigWithCorrelationEnabled().Object,
vulnerabilityCorrelation: null!,
vulnRepo.Object,
Mock.Of<ISessionRepository>(),
inventoryLoader.Object,
Mock.Of<IAnalyzeOnEnrollmentEndProducer>(),
signalRNotification: null!,
GuardFor(row),
NullLogger<VulnerabilityCorrelateHandler>.Instance);
await handler.HandleAsync(new VulnerabilityCorrelateEnvelope
{
TenantId = TenantId, SessionId = SessionId, Reason = VulnerabilityCorrelateHandler.ReasonShutdownInventory, EnqueuedAt = DateTime.UtcNow,
});
inventoryLoader.VerifyNoOtherCalls();
vulnRepo.VerifyNoOtherCalls();
}
[Theory]
[MemberData(nameof(NotLiveRows))]
public async Task Analyze_skips_a_missing_or_locked_session_before_the_rule_engine_runs(TableEntity? row)
{
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Strict);
var metricsRepo = new Mock<IMetricsRepository>(MockBehavior.Strict);
var handler = new AnalyzeOnEnrollmentEndHandler(
ruleService: null!,
ruleRepo.Object,
Mock.Of<ISessionRepository>(),
metricsRepo.Object,
signalRNotification: null!,
configService: null!,
channelDispatcher: null!,
GuardFor(row),
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
await handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
{
TenantId = TenantId, SessionId = SessionId, Reason = AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentComplete, EnqueuedAt = DateTime.UtcNow,
});
ruleRepo.VerifyNoOtherCalls();
metricsRepo.VerifyNoOtherCalls();
}
[Fact]
public async Task A_guard_read_error_propagates_so_the_queue_retries_instead_of_skipping()
{
var reader = new Mock<ISessionDeletionInventoryReader>();
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
var handler = new AnalyzeOnEnrollmentEndHandler(
ruleService: null!, Mock.Of<IRuleRepository>(), Mock.Of<ISessionRepository>(), Mock.Of<IMetricsRepository>(),
signalRNotification: null!, configService: null!, channelDispatcher: null!,
new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance),
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
{
TenantId = TenantId, SessionId = SessionId, Reason = AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentComplete, EnqueuedAt = DateTime.UtcNow,
}));
}
}
@@ -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);
@@ -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 };
}
@@ -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>(),
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
@@ -155,6 +156,27 @@ public class IngestTelemetryAuthPathTests
Assert.Equal(SessionStatus.Succeeded, SessionRowProjections.TryReadStatus(returned));
}
// ============================================================ Session unknown → 410
[Fact]
public async Task SessionUnknown_guard_returns_null_that_Run_maps_to_410_session_unknown()
{
// Run: a null guard row (no Sessions row, no active tombstone) is refused with 410 via
// WriteSessionUnknownAsync and stamped IngestRefusal=session_unknown; the cascade lock keeps
// its own value so the two 410 reasons stay apart on the request row.
var guard = NewGuard(out var reader);
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync((TableEntity?)null);
reader.Setup(r => r.GetActiveSessionTombstoneAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync((TableEntity?)null);
var row = await guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry");
Assert.Null(row);
Assert.NotEqual(RequestRowMarkers.IngestRefusal.SessionUnknown, RequestRowMarkers.IngestRefusal.CascadeLocked);
Assert.Equal("IngestRefusal", RequestRowMarkers.IngestRefusalKey);
}
// ============================================================ TryReadStatus (unique helper)
[Theory]
@@ -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,33 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Maintenance;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
public class MaintenanceStepTimerTests
{
[Fact]
public async Task Records_every_step_in_order_and_returns_the_body_result()
{
var timer = new MaintenanceStepTimer();
await timer.RunAsync("first", () => Task.CompletedTask);
var value = await timer.RunAsync<int>("second", () => Task.FromResult(42));
Assert.Equal(42, value);
Assert.Equal(new[] { "first", "second" }, timer.Steps.Keys);
Assert.All(timer.Steps.Values, ms => Assert.True(ms >= 0));
}
[Fact]
public async Task A_throwing_step_is_still_recorded_and_the_exception_propagates()
{
var timer = new MaintenanceStepTimer();
await Assert.ThrowsAsync<InvalidOperationException>(
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
Assert.True(timer.Steps.ContainsKey("boom"));
}
}
@@ -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);
}
}
@@ -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;
@@ -247,7 +247,6 @@ public class TenantOffboardingWorkerPoisonTests
Worker = new TenantOffboardingWorker(
MainQueue.Object, PoisonQueue.Object, handler,
BuildOpsService(),
NullLogger<TenantOffboardingWorker>.Instance);
}
@@ -121,13 +121,17 @@ public class OffboardingSessionEnumeratorTests
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
public Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
public Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
public Task<int> BackfillSessionIndexAsync() => throw new NotImplementedException();
public Task<int> CleanupGhostSessionIndexEntriesAsync() => throw new NotImplementedException();
public Task<bool> IsSessionIndexEmptyAsync() => throw new NotImplementedException();
public Task<List<OrphanedEventSession>> GetOrphanedEventSessionsAsync(TimeSpan gracePeriod) => throw new NotImplementedException();
public Task DeleteEventSessionIndexEntryAsync(string tenantId, string sessionId) => throw new NotImplementedException();
public Task<HashSet<(string TenantId, string SessionId)>> GetSessionKeysAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<IReadOnlyList<OrphanSessionHandle>> GetEventSessionIndexHandlesAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<OrphanSessionHandle?> GetEventSessionIndexHandleAsync(string tenantId, string sessionId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<string?> StampHandleInventoryDecrementedAsync(string tenantId, string sessionId, string etag, CancellationToken ct = default) => throw new NotImplementedException();
public Task<bool> DeleteEventSessionIndexHandleAsync(string tenantId, string sessionId, string etag, CancellationToken ct = default) => throw new NotImplementedException();
public Task<int> DeleteOrphanSessionRowsAsync(string table, string tenantId, string sessionId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<HashSet<(string TenantId, string SessionId)>> GetEventTypeIndexSessionKeysAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<List<(string TenantId, string SessionId)>> GetAllSessionTombstoneKeysAsync() => throw new NotImplementedException();
}
}
@@ -0,0 +1,116 @@
using System;
using System.Linq;
using AutopilotMonitor.Functions.Services.Maintenance;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the per-table filter of the orphan-session sweep to the writer's key shape. A filter
/// that names the wrong side (tenant where the session belongs, or vice versa) would either
/// miss the session's rows or touch another session's — both silently.
/// </summary>
public class OrphanSessionRowFiltersTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
[Fact]
public void Every_filter_table_has_a_filter_naming_both_ids()
{
foreach (var table in OrphanSessionRowFilters.Tables)
{
var filter = OrphanSessionRowFilters.For(table, TenantId, SessionId);
Assert.Contains(TenantId, filter);
Assert.Contains(SessionId, filter);
}
}
[Theory]
[InlineData(Constants.TableNames.Events)]
[InlineData(Constants.TableNames.RuleResults)]
[InlineData(Constants.TableNames.Signals)]
[InlineData(Constants.TableNames.DecisionTransitions)]
[InlineData(Constants.TableNames.VulnerabilityReports)]
public void Session_partition_tables_filter_on_the_composite_partition_key(string table)
{
Assert.Equal($"PartitionKey eq '{TenantId}_{SessionId}'", OrphanSessionRowFilters.For(table, TenantId, SessionId));
}
[Theory]
[InlineData(Constants.TableNames.DeviceSnapshot)]
[InlineData(Constants.TableNames.SessionTimeBreakdowns)]
public void One_row_per_session_tables_filter_on_tenant_partition_and_session_row_key(string table)
{
Assert.Equal($"PartitionKey eq '{TenantId}' and RowKey eq '{SessionId}'", OrphanSessionRowFilters.For(table, TenantId, SessionId));
}
[Fact]
public void AppInstallSummaries_filter_on_the_SessionId_column()
{
Assert.Equal($"PartitionKey eq '{TenantId}' and SessionId eq '{SessionId}'",
OrphanSessionRowFilters.For(Constants.TableNames.AppInstallSummaries, TenantId, SessionId));
}
[Fact]
public void EventTypeIndex_filter_scans_the_tenant_range_and_matches_the_SessionId_column()
{
// RowKey is {invertedTicks}_{sessionId}; without a Sessions row the ticks are not reconstructible.
Assert.Equal($"PartitionKey ge '{TenantId}_' and PartitionKey lt '{TenantId}_~' and SessionId eq '{SessionId}'",
OrphanSessionRowFilters.For(Constants.TableNames.EventTypeIndex, TenantId, SessionId));
}
[Fact]
public void CveIndex_filter_scans_the_tenant_range_and_matches_the_session_row_key()
{
Assert.Equal($"PartitionKey ge '{TenantId}_' and PartitionKey lt '{TenantId}_~' and RowKey eq '{SessionId}'",
OrphanSessionRowFilters.For(Constants.TableNames.CveIndex, TenantId, SessionId));
}
[Fact]
public void SessionAnnotations_filter_names_every_lane()
{
var filter = OrphanSessionRowFilters.For(Constants.TableNames.SessionAnnotations, TenantId, SessionId);
Assert.StartsWith($"PartitionKey eq '{TenantId}' and (", filter);
foreach (var lane in AnnotationLanes.All)
Assert.Contains($"RowKey eq '{SessionId}_{lane}'", filter);
Assert.Equal(AnnotationLanes.All.Length - 1, filter.Split(" or ").Length - 1);
}
[Fact]
public void SessionTenantLookup_filter_pins_the_row_to_this_tenant()
{
Assert.Equal($"PartitionKey eq '{SessionId}' and RowKey eq 'tenant' and TenantId eq '{TenantId}'",
OrphanSessionRowFilters.For(Constants.TableNames.SessionTenantLookup, TenantId, SessionId));
}
[Fact]
public void Unknown_table_throws_instead_of_guessing()
{
Assert.Throws<ArgumentException>(() => OrphanSessionRowFilters.For(Constants.TableNames.Sessions, TenantId, SessionId));
}
[Theory]
[InlineData("not-a-guid", SessionId)]
[InlineData(TenantId, "'; drop")]
public void Non_guid_ids_throw(string tenantId, string sessionId)
{
Assert.ThrowsAny<ArgumentException>(() => OrphanSessionRowFilters.For(Constants.TableNames.Events, tenantId, sessionId));
}
[Fact]
public void Swept_tables_are_the_filter_tables_plus_inventory_pair_plus_handle()
{
var expected = OrphanSessionRowFilters.Tables
.Concat(new[]
{
Constants.TableNames.SessionInventoryContributions,
Constants.TableNames.SoftwareInventory,
Constants.TableNames.EventSessionIndex,
})
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(expected, OrphanSessionSweeper.SweptTables.ToHashSet(StringComparer.Ordinal));
}
}
@@ -0,0 +1,528 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Backup;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Maintenance;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Behaviour of the orphan-session sweep: which handles become candidates, the re-checks right
/// before the deletes, the inventory claim-then-decrement order, the handle as the last delete,
/// the cap/budget, and the weekly read-only reconciliation. Storage is mocked at the repository
/// seams; the ordered action log is the evidence for every ordering invariant.
/// </summary>
public class OrphanSessionSweeperTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
private const string SessionB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
private const string SessionC = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
// A Wednesday: the weekly reconciliation is not due unless forced or overdue.
private static readonly DateTime Now = new DateTime(2026, 10, 14, 10, 0, 0, DateTimeKind.Utc);
private sealed class Harness
{
public readonly Mock<IMaintenanceRepository> Maint = new();
public readonly Mock<IVulnerabilityRepository> Vuln = new();
public readonly Mock<ISessionDeletionInventoryReader> Reader = new();
public readonly Mock<AdminConfigurationService> AdminConfig;
public readonly Mock<OrphanSweepLockStore> LockStore;
public readonly List<OpsEventEntry> Ops = new();
public readonly List<string> Log = new();
public readonly HashSet<(string TenantId, string SessionId)> SessionKeys = new();
public readonly List<OrphanSessionHandle> Handles = new();
public readonly Dictionary<string, OrphanSessionHandle?> LiveHandles = new(StringComparer.Ordinal);
public readonly HashSet<string> RowsPresent = new(StringComparer.Ordinal);
public readonly HashSet<string> Tombstoned = new(StringComparer.Ordinal);
public readonly Dictionary<string, List<DeletionDecrementKey>> Inventory = new(StringComparer.Ordinal);
public readonly HashSet<(string TenantId, string SessionId)> IndexKeys = new();
public readonly Dictionary<string, int> RowsPerTable = new(StringComparer.Ordinal);
public readonly AdminConfiguration Config = new AdminConfiguration();
public DateTime Clock = Now;
public TimeSpan Budget = TimeSpan.FromMinutes(5);
public bool StampFails;
public bool HandleDeleteFails;
public bool DecrementFails;
public bool LeaseHeld;
public string? FailingTable;
public Harness()
{
Maint.Setup(m => m.GetSessionKeysAsync(It.IsAny<CancellationToken>()))
.Callback(() => Log.Add("sessions"))
.ReturnsAsync(() => new HashSet<(string, string)>(SessionKeys));
Maint.Setup(m => m.GetEventSessionIndexHandlesAsync(It.IsAny<CancellationToken>()))
.Callback(() => Log.Add("handles"))
.ReturnsAsync(() => Handles.ToList());
Maint.Setup(m => m.GetEventSessionIndexHandleAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string s, CancellationToken _) =>
LiveHandles.TryGetValue(s, out var live) ? live : Handles.FirstOrDefault(h => h.SessionId == s));
Maint.Setup(m => m.StampHandleInventoryDecrementedAsync(TenantId, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, CancellationToken>((_, s, _, _) => Log.Add($"stamp:{s}"))
.ReturnsAsync(() => StampFails ? null : "etag-stamped");
Maint.Setup(m => m.DeleteOrphanSessionRowsAsync(It.IsAny<string>(), TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string table, string _, string s, CancellationToken _) =>
{
Log.Add($"delete:{table}:{s}");
if (string.Equals(table, FailingTable, StringComparison.Ordinal))
throw new InvalidOperationException("storage said no");
return Task.FromResult(RowsPerTable.TryGetValue(table, out var n) ? n : 1);
});
Maint.Setup(m => m.DeleteEventSessionIndexHandleAsync(TenantId, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, CancellationToken>((_, s, _, _) => Log.Add($"handle:{s}"))
.ReturnsAsync(() => !HandleDeleteFails);
Maint.Setup(m => m.GetEventTypeIndexSessionKeysAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new HashSet<(string, string)>(IndexKeys));
Vuln.Setup(v => v.GetSessionInventoryContributionsAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string s, CancellationToken _) =>
Inventory.TryGetValue(s, out var keys) ? (IReadOnlyList<DeletionDecrementKey>?)keys : null);
Vuln.Setup(v => v.DecrementSoftwareInventoryEntryAsync(TenantId, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string _, string vendor, string name, string version, CancellationToken _) =>
{
Log.Add($"decrement:{name}");
if (DecrementFails) throw new InvalidOperationException("counter busy");
return Task.CompletedTask;
});
Vuln.Setup(v => v.DeleteSessionInventoryContributionsAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((_, s, _) => Log.Add($"sideRow:{s}"))
.Returns(Task.CompletedTask);
Reader.Setup(r => r.GetSessionRowAsync(TenantId, It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string s, IEnumerable<string> _, CancellationToken _) =>
RowsPresent.Contains(s) ? new TableEntity(TenantId, s) : null);
Reader.Setup(r => r.GetActiveSessionTombstoneAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string s, CancellationToken _) =>
Tombstoned.Contains(s) ? new TableEntity(TenantId, s) : null);
AdminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
AdminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(() => Config);
AdminConfig.Setup(a => a.UpdateAsync(It.IsAny<Func<AdminConfiguration, string?>>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns((Func<AdminConfiguration, string?> mutate, string _, string? _) =>
{
mutate(Config);
return Task.FromResult(new AdminConfigurationUpdateResult { Before = Config, After = Config });
});
LockStore = new Mock<OrphanSweepLockStore>(new SessionDeletionMaintenanceFunctionTests.MaintenanceBlobStub());
LockStore.Setup(l => l.AcquireLeaseAsync(It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
.Returns(() => LeaseHeld
? throw new LeaseHeldException("held", new InvalidOperationException("lease"))
: Task.FromResult(new Mock<Azure.Storage.Blobs.Specialized.BlobLeaseClient>().Object));
}
public OrphanSessionHandle AddHandle(string sessionId, TimeSpan ingestAge, TimeSpan? writtenAge = null, string etag = "etag-1", bool decremented = false)
{
var handle = new OrphanSessionHandle
{
TenantId = TenantId,
SessionId = sessionId,
LastIngestAt = Now - ingestAge,
WrittenAt = Now - (writtenAge ?? TimeSpan.FromDays(1)),
ETag = etag,
InventoryDecremented = decremented,
};
Handles.Add(handle);
return handle;
}
public OrphanSessionSweeper Build()
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (Ops) Ops.Add(e); })
.Returns(Task.CompletedTask);
var ops = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(AdminConfig.Object));
return new OrphanSessionSweeper(Maint.Object, Vuln.Object, Reader.Object, AdminConfig.Object, LockStore.Object, ops,
NullLogger<OrphanSessionSweeper>.Instance, () => Clock, Budget);
}
public Task<OrphanSweepRunResult> RunCoreAsync(bool forceDeepCheck = false) => Build().RunCoreAsync(forceDeepCheck, CancellationToken.None);
}
private static DeletionDecrementKey Key(string name) => new DeletionDecrementKey { Vendor = "v", Name = name, Version = "1" };
// ============================================================ candidates
[Fact]
public async Task Candidates_are_handles_without_a_session_key_outside_both_grace_windows()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3)); // orphan, old
h.AddHandle(SessionB, TimeSpan.FromDays(3)); // has a row
h.SessionKeys.Add((TenantId, SessionB));
h.AddHandle(SessionC, TimeSpan.FromHours(1)); // ingest grace
h.AddHandle("dddddddd-dddd-4ddd-8ddd-dddddddddddd", TimeSpan.FromDays(3), writtenAge: TimeSpan.FromMinutes(30)); // restore grace
var result = await h.RunCoreAsync();
Assert.Equal(1, result.Candidates);
Assert.Equal(SessionA, Assert.Single(result.Cleaned).SessionId);
Assert.Equal(4, result.HandlesScanned);
Assert.Equal(1, result.SessionKeys);
}
[Fact]
public async Task Sessions_are_drained_before_handles()
{
var h = new Harness();
await h.RunCoreAsync();
Assert.Equal(new[] { "sessions", "handles" }, h.Log.Take(2));
}
[Fact]
public async Task Legacy_handles_are_flagged_and_come_first()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.AddHandle(SessionB, Now - OrphanSessionSweeper.LegacyHandleLastIngestAt);
var result = await h.RunCoreAsync();
Assert.Equal(new[] { SessionB, SessionA }, result.Cleaned.Select(c => c.SessionId));
Assert.True(result.Cleaned[0].Legacy);
Assert.False(result.Cleaned[1].Legacy);
}
// ============================================================ re-checks
[Fact]
public async Task Handle_gone_at_recheck_is_skipped_silently()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.LiveHandles[SessionA] = null;
var result = await h.RunCoreAsync();
Assert.Equal(1, result.SkippedHandleGone);
Assert.Empty(result.Cleaned);
Assert.DoesNotContain(h.Log, l => l.StartsWith("delete:", StringComparison.Ordinal));
}
[Fact]
public async Task Handle_changed_since_the_scan_is_skipped()
{
var h = new Harness();
var scanned = h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.LiveHandles[SessionA] = new OrphanSessionHandle
{
TenantId = TenantId, SessionId = SessionA, LastIngestAt = scanned.LastIngestAt, WrittenAt = scanned.WrittenAt, ETag = "etag-later",
};
var result = await h.RunCoreAsync();
Assert.Equal(1, result.SkippedHandleChanged);
Assert.Empty(result.Cleaned);
Assert.DoesNotContain(h.Log, l => l.StartsWith("delete:", StringComparison.Ordinal));
}
[Fact]
public async Task Row_present_at_recheck_skips_everything()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.RowsPresent.Add(SessionA);
var result = await h.RunCoreAsync();
Assert.Equal(1, result.SkippedRowReturned);
Assert.Empty(result.Cleaned);
Assert.DoesNotContain(h.Log, l => l.StartsWith("delete:", StringComparison.Ordinal) || l.StartsWith("stamp:", StringComparison.Ordinal));
}
[Fact]
public async Task Tombstone_counts_as_afterCascade_and_the_session_is_still_cleaned()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.Tombstoned.Add(SessionA);
var result = await h.RunCoreAsync();
Assert.Equal(1, result.AfterCascade);
Assert.True(Assert.Single(result.Cleaned).AfterCascade);
}
// ============================================================ inventory + ordering
[Fact]
public async Task Inventory_is_claimed_then_decremented_then_the_side_row_goes_before_the_tables()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.Inventory[SessionA] = new List<DeletionDecrementKey> { Key("app-1"), Key("app-2") };
var result = await h.RunCoreAsync();
var stamp = h.Log.IndexOf($"stamp:{SessionA}");
var firstDecrement = h.Log.IndexOf("decrement:app-1");
var sideRow = h.Log.IndexOf($"sideRow:{SessionA}");
var firstTable = h.Log.FindIndex(l => l.StartsWith("delete:", StringComparison.Ordinal));
var handle = h.Log.IndexOf($"handle:{SessionA}");
Assert.True(0 <= stamp && stamp < firstDecrement && firstDecrement < sideRow && sideRow < firstTable && firstTable < handle,
string.Join(" | ", h.Log));
var cleaned = Assert.Single(result.Cleaned);
Assert.Equal(2, cleaned.InventoryKeysDecremented);
Assert.Equal(2, result.TotalsByTable[Constants.TableNames.SoftwareInventory]);
Assert.Equal(1, result.TotalsByTable[Constants.TableNames.SessionInventoryContributions]);
}
[Fact]
public async Task A_rerun_with_the_stamp_set_skips_the_decrements_but_still_removes_the_side_row()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3), decremented: true);
h.Inventory[SessionA] = new List<DeletionDecrementKey> { Key("app-1") };
var result = await h.RunCoreAsync();
Assert.DoesNotContain(h.Log, l => l.StartsWith("stamp:", StringComparison.Ordinal) || l.StartsWith("decrement:", StringComparison.Ordinal));
Assert.Contains($"sideRow:{SessionA}", h.Log);
Assert.Single(result.Cleaned);
Assert.Equal(0, result.TotalsByTable[Constants.TableNames.SoftwareInventory]);
}
[Fact]
public async Task A_failed_stamp_aborts_the_candidate_before_any_decrement_or_delete()
{
var h = new Harness { StampFails = true };
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.Inventory[SessionA] = new List<DeletionDecrementKey> { Key("app-1") };
var result = await h.RunCoreAsync();
Assert.Equal(1, result.SkippedHandleChanged);
Assert.Empty(result.Cleaned);
Assert.DoesNotContain(h.Log, l => l.StartsWith("decrement:", StringComparison.Ordinal) || l.StartsWith("delete:", StringComparison.Ordinal));
}
[Fact]
public async Task A_failed_decrement_is_counted_and_the_session_is_still_cleaned()
{
var h = new Harness { DecrementFails = true };
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.Inventory[SessionA] = new List<DeletionDecrementKey> { Key("app-1"), Key("app-2") };
var result = await h.RunCoreAsync();
Assert.Equal(2, result.InventoryDecrementsFailed);
Assert.Single(result.Cleaned);
Assert.Contains($"handle:{SessionA}", h.Log);
}
// ============================================================ the handle last
[Fact]
public async Task Every_filter_table_is_cleared_and_the_handle_is_deleted_last_with_the_stamped_etag()
{
var h = new Harness();
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.Inventory[SessionA] = new List<DeletionDecrementKey> { Key("app-1") };
var result = await h.RunCoreAsync();
foreach (var table in OrphanSessionRowFilters.Tables)
Assert.Contains($"delete:{table}:{SessionA}", h.Log);
Assert.Equal($"handle:{SessionA}", h.Log.Last());
h.Maint.Verify(m => m.DeleteEventSessionIndexHandleAsync(TenantId, SessionA, "etag-stamped", It.IsAny<CancellationToken>()), Times.Once);
Assert.Equal(1, result.TotalsByTable[Constants.TableNames.EventSessionIndex]);
Assert.Equal(OrphanSessionRowFilters.Tables.Length, Assert.Single(result.Cleaned).TotalRows);
}
[Fact]
public async Task A_handle_rewritten_before_the_final_delete_is_kept_and_reported()
{
var h = new Harness { HandleDeleteFails = true };
h.AddHandle(SessionA, TimeSpan.FromDays(3));
var result = await h.RunCoreAsync();
var cleaned = Assert.Single(result.Cleaned);
Assert.True(cleaned.HandleKept);
Assert.Equal(0, result.TotalsByTable[Constants.TableNames.EventSessionIndex]);
}
[Fact]
public async Task A_failing_table_leaves_the_handle_and_the_run_continues_with_the_next_candidate()
{
var h = new Harness { FailingTable = Constants.TableNames.Signals };
h.AddHandle(SessionA, TimeSpan.FromDays(4));
h.AddHandle(SessionB, TimeSpan.FromDays(3));
// Only session A's Signals delete fails: the harness throws for the table regardless of
// session, so flip the failure off once A has been attempted.
h.Maint.Setup(m => m.DeleteOrphanSessionRowsAsync(Constants.TableNames.Signals, TenantId, SessionB, It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await h.RunCoreAsync();
Assert.Equal(1, result.FailedSessions);
Assert.DoesNotContain($"handle:{SessionA}", h.Log);
Assert.Contains($"handle:{SessionB}", h.Log);
Assert.Equal(SessionB, Assert.Single(result.Cleaned).SessionId);
}
// ============================================================ cap + budget
[Fact]
public async Task The_cap_leaves_the_rest_for_the_next_run()
{
var h = new Harness();
for (var i = 0; i < OrphanSessionSweeper.MaxCandidatesPerRun + 3; i++)
h.AddHandle(Guid.NewGuid().ToString(), TimeSpan.FromDays(2) + TimeSpan.FromMinutes(i));
var result = await h.RunCoreAsync();
Assert.Equal(OrphanSessionSweeper.MaxCandidatesPerRun, result.Cleaned.Count);
Assert.Equal(3, result.Remaining);
Assert.False(result.BudgetExhausted);
}
[Fact]
public async Task An_exhausted_budget_stops_before_the_next_candidate()
{
var h = new Harness { Budget = TimeSpan.Zero };
h.AddHandle(SessionA, TimeSpan.FromDays(3));
h.AddHandle(SessionB, TimeSpan.FromDays(3));
var result = await h.RunCoreAsync();
Assert.True(result.BudgetExhausted);
Assert.Empty(result.Cleaned);
Assert.Equal(2, result.Remaining);
}
// ============================================================ weekly reconciliation
public static IEnumerable<object?[]> DeepCheckCases()
{
var sunday0845 = new DateTime(2026, 10, 18, 8, 45, 0, DateTimeKind.Utc);
var sunday0445 = new DateTime(2026, 10, 18, 4, 45, 0, DateTimeKind.Utc);
var tuesday = new DateTime(2026, 10, 20, 12, 45, 0, DateTimeKind.Utc);
yield return new object?[] { null, sunday0845, true }; // never ran: first Sunday window
yield return new object?[] { null, tuesday, false }; // never ran: wait for Sunday
yield return new object?[] { sunday0845.AddDays(-7), sunday0845, true }; // weekly
yield return new object?[] { sunday0845.AddDays(-7), sunday0445, false }; // Sunday, but before 08:00
yield return new object?[] { sunday0845.AddDays(-3), sunday0845, false }; // ran this week already
yield return new object?[] { tuesday.AddDays(-9), tuesday, true }; // missed Sunday: overdue
}
[Theory]
[MemberData(nameof(DeepCheckCases))]
public void Deep_check_is_due_on_Sunday_mornings_or_when_overdue(DateTime? lastRun, DateTime now, bool due)
{
Assert.Equal(due, OrphanSessionSweeper.IsDeepCheckDue(lastRun, now));
}
[Fact]
public async Task Reconcile_reports_only_sessions_without_a_row_and_without_a_handle_and_stamps_the_marker()
{
var h = new Harness();
const string noHandleNoRow = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const string noHandleButRow = "ffffffff-ffff-4fff-8fff-ffffffffffff";
h.AddHandle(SessionA, TimeSpan.FromDays(3)); // in the sweep's own pipeline
h.SessionKeys.Add((TenantId, SessionB)); // live
h.RowsPresent.Add(noHandleButRow); // registered after the drain
h.IndexKeys.UnionWith(new[] { (TenantId, SessionA), (TenantId, SessionB), (TenantId, noHandleNoRow), (TenantId, noHandleButRow) });
var result = await h.RunCoreAsync(forceDeepCheck: true);
var reconcile = Assert.IsType<OrphanReconcileResult>(result.Reconcile);
Assert.Equal(4, reconcile.IndexSessions);
Assert.Equal(1, reconcile.ResidueSessions);
Assert.Equal((TenantId, noHandleNoRow), Assert.Single(reconcile.Sample));
var evt = Assert.Single(h.Ops, e => e.EventType == OpsEventTypes.OrphanReconcileCompleted);
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
Assert.Equal(Now.ToString("o"), h.Config.OrphanDeepCheckLastRunUtc);
}
[Fact]
public async Task Reconcile_without_residue_is_Info_and_is_not_due_again_mid_week()
{
var h = new Harness();
h.SessionKeys.Add((TenantId, SessionB));
h.IndexKeys.Add((TenantId, SessionB));
var first = await h.RunCoreAsync(forceDeepCheck: true);
var evt = Assert.Single(h.Ops, e => e.EventType == OpsEventTypes.OrphanReconcileCompleted);
Assert.Equal(OpsEventSeverity.Info, evt.Severity);
Assert.Equal(0, first.Reconcile!.ResidueSessions);
var second = await h.RunCoreAsync();
Assert.Null(second.Reconcile);
}
// ============================================================ RunAsync: lease + ops events
[Fact]
public async Task A_timer_run_writes_the_heartbeat_and_EventsCleaned_only_when_something_was_cleaned()
{
var quiet = new Harness();
Assert.True(await quiet.Build().RunAsync(OrphanSessionSweeper.TimerTrigger, CancellationToken.None));
Assert.Single(quiet.Ops, e => e.EventType == OpsEventTypes.OrphanSweepCompleted && e.Severity == OpsEventSeverity.Info);
Assert.DoesNotContain(quiet.Ops, e => e.EventType == OpsEventTypes.OrphanEventsCleaned);
Assert.DoesNotContain(quiet.Ops, e => e.EventType == OpsEventTypes.OrphanReconcileCompleted);
var busy = new Harness();
busy.AddHandle(SessionA, TimeSpan.FromDays(3));
Assert.True(await busy.Build().RunAsync(OrphanSessionSweeper.TimerTrigger, CancellationToken.None));
var cleaned = Assert.Single(busy.Ops, e => e.EventType == OpsEventTypes.OrphanEventsCleaned);
Assert.Equal(OpsEventSeverity.Warning, cleaned.Severity);
Assert.Contains(SessionA, cleaned.Details ?? string.Empty);
}
[Fact]
public async Task A_manual_run_always_includes_the_reconciliation()
{
var h = new Harness();
Assert.True(await h.Build().RunAsync("alice@contoso.com", CancellationToken.None));
Assert.Single(h.Ops, e => e.EventType == OpsEventTypes.OrphanReconcileCompleted);
}
[Fact]
public async Task A_held_lease_records_SkippedLocked_and_touches_nothing()
{
var h = new Harness { LeaseHeld = true };
h.AddHandle(SessionA, TimeSpan.FromDays(3));
Assert.False(await h.Build().RunAsync(OrphanSessionSweeper.TimerTrigger, CancellationToken.None));
Assert.Single(h.Ops, e => e.EventType == OpsEventTypes.OrphanSweepSkippedLocked);
Assert.Empty(h.Log);
}
[Fact]
public async Task A_failing_scan_records_OrphanSweepFailed()
{
var h = new Harness();
h.Maint.Setup(m => m.GetSessionKeysAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("storage down"));
Assert.True(await h.Build().RunAsync(OrphanSessionSweeper.TimerTrigger, CancellationToken.None));
var failed = Assert.Single(h.Ops, e => e.EventType == OpsEventTypes.OrphanSweepFailed);
Assert.Equal(OpsEventSeverity.Error, failed.Severity);
Assert.DoesNotContain(h.Ops, e => e.EventType == OpsEventTypes.OrphanSweepCompleted);
}
}
@@ -0,0 +1,111 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Maintenance;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Shape of the ops events the orphan sweep and the maintenance run write: the dashboards and the
/// health pass read these details by name.
/// </summary>
public class OrphanSweepOpsEventTests
{
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
}
[Fact]
public async Task MaintenanceCompleted_carries_the_step_timings()
{
var (service, saved) = Rig();
var steps = new Dictionary<string, long> { ["stalledSessions"] = 1200, ["aggregateMetrics"] = 90000 };
await service.RecordMaintenanceCompletedAsync(123456, "Timer", stepsMs: steps);
var evt = Assert.Single(saved);
Assert.Equal(OpsEventTypes.MaintenanceCompleted, evt.EventType);
Assert.Contains("\"stepsMs\"", evt.Details);
Assert.Contains("\"aggregateMetrics\":90000", evt.Details);
}
[Fact]
public async Task OrphanEventsCleaned_is_a_Warning_with_per_table_totals_and_the_legacy_count()
{
var (service, saved) = Rig();
var result = new OrphanSweepRunResult { Remaining = 7 };
var legacy = new OrphanSweepCleanedSession { TenantId = "t", SessionId = "s-legacy", Legacy = true, InventoryKeysDecremented = 3, HadInventorySideRow = true };
legacy.RowsByTable[Constants.TableNames.Events] = 18;
legacy.RowsByTable[Constants.TableNames.EventTypeIndex] = 13;
result.Add(legacy);
var fresh = new OrphanSweepCleanedSession { TenantId = "t", SessionId = "s-fresh" };
fresh.RowsByTable[Constants.TableNames.Events] = 1;
result.Add(fresh);
await service.RecordOrphanEventsCleanedAsync(result);
var evt = Assert.Single(saved);
Assert.Equal(OpsEventTypes.OrphanEventsCleaned, evt.EventType);
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
Assert.Contains("\"orphanSessions\":2", evt.Details);
Assert.Contains("\"totalEventsDeleted\":19", evt.Details);
Assert.Contains("\"legacySessions\":1", evt.Details);
Assert.Contains("\"remaining\":7", evt.Details);
Assert.Contains($"\"{Constants.TableNames.SoftwareInventory}\":3", evt.Details);
Assert.Contains($"\"{Constants.TableNames.EventSessionIndex}\":2", evt.Details);
Assert.Contains("\"legacy\":true", evt.Details);
}
[Fact]
public async Task OrphanSweepCompleted_is_the_Info_heartbeat_with_the_counters()
{
var (service, saved) = Rig();
var result = new OrphanSweepRunResult
{
HandlesScanned = 16784, SessionKeys = 16700, Candidates = 3, Remaining = 1, SkippedHandleGone = 2, DurationMs = 4200,
Reconcile = new OrphanReconcileResult { IndexSessions = 16000, ResidueSessions = 0, DurationMs = 180000 },
};
await service.RecordOrphanSweepCompletedAsync(result, "Timer");
var evt = Assert.Single(saved);
Assert.Equal(OpsEventTypes.OrphanSweepCompleted, evt.EventType);
Assert.Equal(OpsEventSeverity.Info, evt.Severity);
Assert.Contains("\"handlesScanned\":16784", evt.Details);
Assert.Contains("\"skippedHandleGone\":2", evt.Details);
Assert.Contains("\"residueSessions\":0", evt.Details);
}
[Fact]
public async Task OrphanReconcileCompleted_flips_to_Warning_with_a_sample_when_residue_exists()
{
var (service, saved) = Rig();
var clean = new OrphanReconcileResult { IndexSessions = 10, ResidueSessions = 0 };
var dirty = new OrphanReconcileResult { IndexSessions = 10, ResidueSessions = 2, Sample = { ("t1", "s1"), ("t1", "s2") } };
await service.RecordOrphanReconcileCompletedAsync(clean);
await service.RecordOrphanReconcileCompletedAsync(dirty);
Assert.Equal(OpsEventSeverity.Info, saved[0].Severity);
Assert.Equal(OpsEventSeverity.Warning, saved[1].Severity);
Assert.Contains("\"sessionId\":\"s2\"", saved[1].Details);
Assert.Contains("\"sampleTruncated\":false", saved[1].Details);
}
}
@@ -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);
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure.Data.Tables;
@@ -133,6 +134,60 @@ public class SessionDeletionGuardTests
Assert.Equal(SessionId, ex.SessionId);
}
// ============================================================ GetLivenessAsync (derived writers) ====
[Fact]
public async Task GetLivenessAsync_reports_Missing_when_the_row_does_not_exist()
{
// Unlike the ingest contract, a derived writer (queue handler, rescan) stops here: nothing
// may be derived for a session the backend does not know.
var guard = NewGuard(out var reader);
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TableEntity?)null);
Assert.Equal(SessionLiveness.Missing, await guard.GetLivenessAsync(TenantId, SessionId, "VulnerabilityCorrelate"));
reader.Verify(r => r.GetActiveSessionTombstoneAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData(SessionDeletionState.Preparing)]
[InlineData(SessionDeletionState.Queued)]
[InlineData(SessionDeletionState.Running)]
[InlineData(SessionDeletionState.Poisoned)]
public async Task GetLivenessAsync_reports_Locked_for_every_lock_state(string state)
{
var guard = NewGuard(out var reader);
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TableEntity(TenantId, SessionId) { ["DeletionState"] = state });
Assert.Equal(SessionLiveness.Locked, await guard.GetLivenessAsync(TenantId, SessionId, "AnalyzeOnEnrollmentEnd"));
}
[Theory]
[InlineData(null)]
[InlineData("None")]
public async Task GetLivenessAsync_reports_Live_for_an_unlocked_row(string? state)
{
var guard = NewGuard(out var reader);
var row = new TableEntity(TenantId, SessionId);
if (state != null) row["DeletionState"] = state;
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
Assert.Equal(SessionLiveness.Live, await guard.GetLivenessAsync(TenantId, SessionId, "VulnerabilityRescan"));
}
[Fact]
public async Task GetLivenessAsync_propagates_a_read_error()
{
// A storage failure must surface to the queue worker (retry/poison), never read as "missing".
var guard = NewGuard(out var reader);
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => guard.GetLivenessAsync(TenantId, SessionId, "VulnerabilityCorrelate"));
}
// ============================================================ Codex F3: tombstone-marker disambiguation ====
[Fact]
@@ -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);
@@ -81,6 +81,32 @@ public class TableLifecycleBucketTests
Constants.TableNames.DelegationInvitations,
};
/// <summary>
/// The orphan-session sweep is the cascade for sessions without a Sessions row, so it must
/// clear exactly the tables the cascade manifest clears — minus the tombstone pair, which does
/// not exist for such a session. A new manifest step without a sweep entry (or the reverse)
/// fails here until someone decides the table's orphan lifecycle.
/// </summary>
[Fact]
public async Task OrphanSweep_ClearsEveryPerSessionCascadeTable_ExceptTheTombstonePair()
{
var cascade = await PerSessionCascadeTablesAsync();
cascade.Remove(Constants.TableNames.Sessions);
cascade.Remove(Constants.TableNames.SessionsIndex);
var swept = AutopilotMonitor.Functions.Services.Maintenance.OrphanSessionSweeper.SweptTables.ToHashSet(StringComparer.Ordinal);
var missingFromSweep = cascade.Except(swept, StringComparer.Ordinal).OrderBy(t => t, StringComparer.Ordinal).ToList();
var sweptButNotCascaded = swept.Except(cascade, StringComparer.Ordinal).OrderBy(t => t, StringComparer.Ordinal).ToList();
Assert.True(missingFromSweep.Count == 0,
"Per-session cascade tables the orphan sweep does not clear — add them to OrphanSessionRowFilters.Tables "
+ "(with the writer's key shape) or handle them in OrphanSessionSweeper:\n - " + string.Join("\n - ", missingFromSweep));
Assert.True(sweptButNotCascaded.Count == 0,
"Orphan sweep clears tables the cascade manifest does not know — add a manifest step first:\n - "
+ string.Join("\n - ", sweptButNotCascaded));
}
[Fact]
public void TableNamesConstants_SetEquals_TableNamesAll()
{
@@ -209,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" },
@@ -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>());
@@ -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);
@@ -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

Some files were not shown because too many files have changed in this diff Show More