Compare commits

..

19 Commits

Author SHA1 Message Date
Oliver Kieselbach 52ae6541a7 feat(agent): signal-pipeline health block in agent_metrics_snapshot
The 2026-09-01 WG-seal soak on post-SignalIngress-fix builds (memory:
project_wg_seal_postfix_soak_residual) found sessions whose DecisionEngine
never sealed Part 1 while the agent kept running for hours - and telemetry
could not distinguish a frozen ingress worker from a deliberate Weak
sealing-classifier verdict, because the metrics snapshots carried no
pipeline counters at all.

Every agent_metrics_snapshot now carries the block. The fields are MONOTONIC
totals plus a monotonic queue peak - deliberately no momentary queue length:
a snapshot samples the integral, so any two snapshots prove whether the
worker ran in between (a frozen worker keeps signal_pending_count growing
while processed/applied stand still; a momentary gauge would alias between
ticks). Same field pattern the snapshot already uses for the spool
(spool_total_enqueued_count / spool_peak_pending_item_count).

- signal_last_ordinal / signal_processed_total (new ProcessedSignalCount) /
  signal_pending_count / signal_reentrant_total: SignalIngress worker
  progress; signal_queue_peak is a new lock-free Interlocked-max recorded in
  Post (fast + slow path; worker-local follow-up items deliberately excluded)
- decision_stage + decision_last_applied_ordinal: from the immutable
  DecisionState - answers the WG question directly ("minutes after the
  tracker's whiteglove_complete and still not WhiteGloveSealed")
- wg_sealing_level / wg_sealing_score: only after the sealing classifier
  produced at least one verdict (key ABSENCE, not Unknown/0 noise, for every
  non-WG session) - separates "classifier said Weak" from "never ran"

Wiring is a read-only probe chain following the imeTrackerHealthProbe
pattern: EnrollmentOrchestrator passes () => _processor?.CurrentState into
IComponentFactory.CreateCollectorHosts (new optional decisionStateProbe;
test fakes updated), PeriodicCollectorLifecycleHost combines it with its
existing concrete-SignalIngress reference in BuildSignalPipelineProbe, and
AgentSelfMetricsCollector writes the fields fail-soft (a throwing probe
degrades to a snapshot without the block - pinned by test). The snapshot
event travels the collector/event path, which is independent of the signal
pipeline it measures - field-proven by the freeze cases, whose perf/metrics
events kept flowing while the engine stood still.

No backend/web/MCP change needed: no new event type, DataJson is free-form.

Tests: 5 new collector facts (full block, absence semantics x2, fail-soft,
no-probe compatibility) + 2 new SignalIngress facts (processed total stable
across drain; queue peak survives the queue draining - the property a
momentary gauge would not have). Agent suite 2812 green, solution builds
with 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 18:26:22 +02:00
Oliver Kieselbach d763a885e2 Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad

Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer
tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad.
Ein neuer Provider haette beide anfassen muessen.

Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider —
WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST.
SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es
keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession,
ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification).
SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService
bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern.

Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token
gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb
ist das Gate eine Server-Regel, keine versteckte Dropdown-Option:
ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE
Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der
Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter
Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann.
Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und
wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft.

Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList /
RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin,
statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson +
GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload,
NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0).

Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von
Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im
Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus
SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar,
damit ein Speichern ihn nicht still verwirft.

Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den
SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate
invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests
decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub,
der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots

OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in
AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel
wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden
hiess also: das Sales-Team bekommt auch jede Security-Regel.

Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe
NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds
bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und
vereinigt ihre Ziele.

Rueckwaertskompatibel ohne Migrationsjob:
- Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot
  einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und
  uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet.
- GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor
  zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert.
  Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher
  wegdriftet.
- Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel
  traegt, also verhaelt sich am Deploy-Tag nichts anders.
- Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel,
  deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles.

Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr
geschrieben — aufraeumen erst, wenn produktiv Channels stehen.

Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von
RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen
Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes
Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die
auch die Tenant-Liste passiert.

Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog
registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und
wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen.

Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext
mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen
Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der
gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt
ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine
Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf
positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine
Webhook-URL in den falschen Provider-Slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend): TenantTrialStarted + Event-Payload optional pro Regel

Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der
GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den
Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event.

RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden
per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist
fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad
haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still,
ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null)
ist ein Downgrade und bleibt bei TenantPlanDowngraded.

Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein
Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst:
ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen,
und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das
Informationsniveau aller bestehenden Regeln bleibt damit unveraendert.

ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch
eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und
ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln.

Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte
<= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext
dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer
gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein
stabiler Schluessel zum Verzweigen.

Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die
Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung,
schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben
ungetestet — Suite-Konvention ist, die reinen Kerne zu testen.

Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md +
docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events
Query-Surface), und ein Teil-Commit darin haette es mitgenommen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies

The ops-events read could only narrow by category and date window, so answering
"did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and
filtering it client-side — and for the MCP tool that client is a language model,
so the question cost a category-sized response in model context.

Ops-events filter surface (mirrors the audit-log filter pattern one-for-one):
- OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters +
  pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters ->
  OData clauses). BOTH filter builders call the appender — the single-category /
  unpaged one and the per-partition fan-out builder that adds the RowKey bound —
  so "no category named" cannot silently return unfiltered rows.
- minSeverity expands to an OR-set, not a range: Table Storage compares strings
  lexicographically and the severity names sort Critical < Error < Info <
  Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half.
  The ladder now lives in OpsEventSeverity.Rank, shared with
  OpsAlertDispatchService, so "Warning and above" in a read means what it meant
  when the alert rules fired.
- severity is normalised onto the canonical vocabulary and an unknown value is a
  400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an
  empty page that reads exactly like "no such events happened". eventType stays
  verbatim — stored rows outlive the vocabulary, so a retired type must remain
  searchable in the historical window.
- Every filter value is folded into the continuation fingerprint and echoed on
  nextLink; extras are appended AFTER the pre-existing category/tenantId
  discriminators and skipped when empty, so tokens minted before the filters
  existed still fingerprint identically. The tenantId drill deliberately stays
  in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing).
- MCP get_ops_events gains eventType / severity / minSeverity plus a `days`
  shorthand resolved client-side to a concrete dateFrom (a backend-side "last N
  days" would re-resolve "now" on every page and blow the token fingerprint);
  an explicit bound always wins over the shorthand.

Fixed alongside: the paged cross-category fan-out skipped Platform (Azure
Monitor alerts) because its category list had drifted from OpsEventCategory —
written from day one, invisible to every paged reader, still visible on the
unpaged full-table path. The list is now OpsEventCategory.All plus a reflection
coverage test.

That drift had a structural cause, so the same round closes it for the MCP as a
whole: wire TYPES reached the MCP from day one, but vocabularies did not — types
are erased at runtime and z.enum() needs values, so every tool schema hand-typed
its own copy with nothing to compare against. The copies had drifted:
search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete
— both real stored statuses), both event readers offered 4 of 6 severities (no
Debug, no Trace), get_ops_events named 6 of 7 ops categories.

- The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES:
  session statuses, event severities, ops categories/severities/types,
  annotation lanes/verdicts) from the manifest sections listed in
  MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops
  vocabularies. Freshness is pinned twice — byte-equality against a fresh
  codegen run in the MCP suite, and the shared-manifests-in-sync CI job.
- Tool schemas derive their z.enum() from it and descriptions interpolate the
  list instead of spelling it.
- vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..])
  must sit on a baseline naming why it has no backend owner (MCP-local knobs,
  plus rule type / connection type / CVE risk, which have no C# constants class
  yet). A new hand-typed list fails, and so does a stale baseline entry.
- OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the
  OpsEventService write sites use the constants (a raw literal now fails
  OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection),
  the manifest exports them, and get_resource(name="ops_event_types") serves
  categories + severities + types to the model from the generated copy.

Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent.

Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an
older backend gets silently unfiltered rows back and presents them as filtered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: register the ops notification channels doc in the index and log

The concept doc landed with the feature commits; its index entry and log entry
were still uncommitted in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:57:41 +02:00
dependabot[bot] eedca11214 build(deps): bump the web group (#139)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.11.3` | `12.11.5` |
| [next](https://github.com/vercel/next.js) | `16.3.2` | `16.3.3` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.2` | `16.3.3` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.4` | `19.2.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.2` | `16.3.3` |


Updates `@xyflow/react` from 12.11.3 to 12.11.5
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.5/packages/react)

Updates `next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.2...v16.3.3)

Updates `@next/bundle-analyzer` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/next-bundle-analyzer)

Updates `@types/react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `eslint-config-next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 15:53:05 +02:00
dependabot[bot] 73a6817713 build(deps): bump github/codeql-action from 4.37.3 to 4.37.9 (#138)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.3 to 4.37.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.3...v4.37.9)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 15:52:26 +02:00
Oliver Kieselbach 0a0c323f73 fix(backend): CMTrace skew tripwire judges only the current ingest era
Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions
e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min,
residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those
sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent
2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00,
while the Part 2 leg that completed that morning is clean at -0.16. The
terminal reconcile scans the whole event partition, so the old leg supplied the
majority of the IME samples and fired against a build the device had
self-updated to minutes earlier. Real skew, 12 days old, already fixed - and
>=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat
it.

Event rows carry no agent version, so the split is drawn on ReceivedAt:
ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest
one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg
upload gap (self-metrics every few minutes, session_stalled at 60 min idle,
maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid
residual, conformity AND the distinct-batch guard now all run on the same
era-filtered sample set; the batch counters used to come from the full scan.
SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST
samples (chunked trim) - keeping the head would have blinded the detector on
exactly the sessions large enough to reach it. The ops event reports
eraStartUtc plus the excluded per-side counts, so an inherited leg stays
visible without being alarmed on.

Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly
those three red). The handover fact also asserts that the old leg judged on its
own still fires, so it is the window that silences it, not a moved threshold.
Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 13:53:59 +02:00
Oliver Kieselbach bac26def52 commands: bump to ad441d6 (ux-insights gegen die reale Telemetrie verifiziert)
Zwei Fehlbefunde des 30-Tage-Reports als Warnung im Skill verankert (SignalR-Reconnect-Ratio,
Consent-Funnel aus Frontend-Events), Datenmodell korrigiert (/progress liegt auf portal.,
dcount(user_Id) ist wegen disableCookiesUsage keine Personenzahl, pageViews.duration ist 0,
exceptions-Tabelle ist leer) und die Event-Referenz vollstaendig aus dem Web-Code regeneriert.
Alle 35 Queries der Datei einzeln ausgefuehrt, 35/35 gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:17:53 +02:00
Oliver Kieselbach 4c70ea8a0a feat(web): /buy — Marketplace card first, direct-purchase channel named Cleverbridge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:15:28 +02:00
Oliver Kieselbach eaef1601d0 feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:

WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).

UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.

ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.

Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.

Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.

Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.

Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.

Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:22:16 +02:00
Oliver Kieselbach 51737174b2 feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:32:51 +02:00
Oliver Kieselbach c04280cf68 feat(mcp): MCP server adopts the generated wire types — the codegen now writes a second copy of wire-types.generated.ts into McpServer/src/generated (MCP_HEADER, buildMcpWireTypesSource, same freshness contract), pinned by a new MCP vitest freshness test (repo-root discovery like event-types-drift, byte-equal to a fresh codegen run, mutation-proven red/green) and enforced in the CI shared-manifests-in-sync diff check; the hand-declared response shapes and structural inline casts in tools/*.ts now import the generated types on the INPUT side only (tool output contracts unchanged): sessions.ts (GetSessionResponse/GetSessionEventsResponse/GetRuleResultsResponse/GetSessionAnnotationsResponse/DiagnosticsDownloadTicketResponse; get_session_summary events are Partial<EnrollmentEvent>, RuleResult drops the dead ruleTitle??title fallback), admin.ts (usage union GetMcpUserUsage/GetGlobalMcpUsage/GetGlobalMcpUsageDaily, GeographicLocationSessionsLeanResponse, GetAllTenantConfigurationsResponse, QueryRawTableResponse, GetUnmatchedSoftwareResponse, SoftwareInventoryResponse; the client-side pagers pin their wire-critical records/summaries/inventory key names via satisfies keyof so a backend rename goes tsc-red instead of silently paging nothing), search.ts (GetSessionEventsResponse on both event readers, SearchSessionsResponse on the legacy fallback, RawEventsPage = Partial<Pick<QueryRawEventsResponse,...>>), rules.ts (DryRunAnalyzeRuleResponse envelope; the inner RuleDryRun trace stays a local reader because the wire type is deliberately unknown); the raw-session fallback in get_session_diagnostics/get_session_summary is removed — { success, session } IS the pinned contract, and the diag test now asserts a non-enveloped object surfaces as a loud tool error instead of being silently absorbed; rule-stats, global/metrics/platform and verdict-calibration keep local readers (backend still serializes anonymous objects there — no DTO exists to import); MCP suite 617 green, tsc green, generator idempotent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 03:19:47 +02:00
Oliver Kieselbach d055eed074 Typed API contract: every success response is a wire DTO, generated end-to-end into TypeScript (#136)
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract)

IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the
ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic
OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success
bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the
single source of the production wire settings (camelCase, WhenWritingNull, string
enums) used by Program.cs AND the three endpoint-test harnesses (two serialized
PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the
deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests
with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites
unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files —
error-shaped bodies stay anonymous by design) and a flatness/location reflection
guard (IApiResponse implementers derive from object and live in Shared, protecting
JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new
serialization proof each converted site must add

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract)

34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs
in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) —
minimal in-place transformation, call structure/status codes untouched, declaration
order == wire order, nullable exactly where a site can emit null (WhenWritingNull);
fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/
EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no
DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report
aggregations now build named item types instead of anonymous ones, and shared shapes
collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2,
SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null
nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent
fixture copied from the pre-migration site) against the new DTO ordinally, with a
null case per vanishing key; ratchet baselines shrink 44->14 helper sites and
134->122 write sites; the three distress-report test files read the now-typed
aggregation items directly instead of via reflection-ToDynamic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract)

the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5,
Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/
Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the
14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/
Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics
ApiModels; GeographicApiModels extended) — same minimal in-place transformation as
batch 1: declaration order == wire order, nullable exactly where a site can emit
null, dual success/failure sites keep their status-code logic and reuse
SuccessMessageResponse, MCP-critical key names (records/summaries/inventory)
pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary,
CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult,
CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes,
netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees
them; payload types that must stay in the Functions assembly (RuleDryRun,
PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto,
Backfill/Reclassification/MaintenanceResult, TenantAdminEntity,
TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type
serialization, proven by the parity facts; 126 new wire-parity facts total
(Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19,
Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both
TypedResponseGuard baselines are now EMPTY regression guards and the untyped
OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body
no longer compiles through the helper and fails the guard through raw
WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by
design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests
reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract)

SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay
byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched),
the consumer-less sessionSummary section is gone, and a new types section carries
every wire type — roots are all IApiResponse implementers plus [WireContract]-marked
types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new
WireTypeManifestBuilder over refs, collections, dictionaries, enums and
[ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/
[JsonIgnore], get-only members export readonly, a nullable slot exports optional
(WhenWritingNull omits the KEY — explicit null survives only inside collections and
dictionary values, so the nullable flag lives on nested descriptors only), field
order follows STJ's derived-before-base serialization for the one legacy inherited
wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string
dictionary keys, foreign BCL classes) fail the build instead of degrading to
unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality
warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot
resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw
0x01 control char and an unescaped ampersand in existing comments).
generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces +
string-union enums, 2.7k lines) next to the lean literal module, npm run
generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new
shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract)

types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over
utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel
importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union
narrowing and the client-side OpsAlertRule view) — the generated types are never
weakened, consumers adapt: durationSeconds is now optional as on the wire (int? —
consumers guard with ?? 0, which also retires the latent NaN-minutes display on
in-progress sessions), status is the SessionStatus union (fixtures on canonical
values; the generic string-sort test keeps its own fixture type), required bools
break hand-built fixtures once → new test/factories.ts makeSession/makeEvent
(Object.assign, not spread — Partial spread re-widens and tests must force
data: undefined); the string|null lifecycle branches were already truthiness-based.
Duplicate local types deleted: geographic-performance now uses the real
LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated
QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a
number — C# int, rendered via statusLabels); sessionExportUtils aliases
SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV
output unchanged; the three structural ReplayInputEvent clones (InstallEvent,
ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the
shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md
updated. tsc 0, vitest 957, eslint 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 02:30:39 +02:00
Oliver Kieselbach 2b6565b3cb fix(agent): IME matcher line budget charges thread CPU cycles, regex timeouts get one starvation-gated retry - the wall-clock budget and the 1s .NET regex timeout both measured guest-scheduler starvation as match cost on CPU-saturated Hyper-V VMs (session b9f1d134: PS-AGENT-OUTPUT timed out on a line it genuinely matches in 5.8us, factor ~170,000, with 2.5s of process-wide log silence and CPU pinned at 100% during the DeviceSetup script burst; six spurious budget breaks the same pass); MatchLine now charges the per-line budget in cycles the poll thread actually executed (QueryThreadCycleTime, probed once, Stopwatch wall-tick fallback preserving the old behaviour exactly), and a RegexMatchTimeoutException whose attempt consumed < 0.5s of match cost (starvation, not spinning) is retried exactly once - counted into the new regexTimeoutRetries (recovered, NOT skipped work, no degraded event) while an attempt at/over the gate is skipped without a second helping of CPU, so regexTimeouts now means "really burned CPU"; the gate is unreachable in the wall-clock fallback by construction (no retry there); retries surfaced through health snapshot, restart-safe persistence, ime_tracker_degraded, ime_pattern_hits and agent_metrics_snapshot (ime_regex_timeout_retries); known limit: hypervisor-level vCPU steal stays invisible to the guest kernel and still charges cycles - strict improvement over wall clock, not perfect steal accounting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:49:15 +02:00
Oliver Kieselbach 5f3ae2d06b fix(backend): SessionTenantLookup is claimed first-writer-wins at registration — the sessionId→tenantId row that every Global Admin/Reader cross-tenant session resolve trusts was an unconditional last-writer-wins upsert AFTER the session write keyed by the client-supplied sessionId, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-tenantId GA read and GA write (globaladmin annotation lane, restore fallback) silently served from a forged session; StoreSessionAsync now claims the row create-only BEFORE any Sessions/SessionsIndex write (409 → read owner: same tenant = re-registration, foreign tenant = SessionTenantConflictException → 409 session_owner_mismatch without disclosing the owner + SessionTenantConflict ops event, nothing written, not fail-soft), the legacy self-heal write is Add-only, ResolveSessionScopeAsync(requireGlobalAdmin) corroborates the resolved tenant against its Sessions row (silent, no operator confirmation), and scripts/Migration/Backfill-SessionTenantLookup.ps1 closed the pre-table window (13509 sessions, 8795 rows added, verify dry-run added=0); no tombstone on deletion by design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:34:50 +02:00
Oliver Kieselbach 83e15e49de fix(core,backend): every payload-derived string stored into DecisionState is bounded (FactStringBounds, 256) and the reducer-verification replay streams signals — RealmJoinFacts.WithPackageStarted/Completed left PackageId/Version/Scope unbounded and deduped via a linear ordinal compare over up to 200 rows, so a forged 5000-signal stream with ~400 KB near-identical package ids cost minutes of CPU per GET sessions/{id}/reducer-verification while ReducerVerifier held all decoded payloads (~GBs) live; keys are now bounded BEFORE the dedupe scan (forged near-duplicates collapse to one row), Scope normalised to machine/user, and the same bound applied to failed app ids, HelloOutcome, ImeMatchedPatternId, EspAdvisoryFailureCategory and RJ product version/channel incl. WithVersionOverride — never truncating real telemetry (registry key names ≤ 255, GUIDs, enum-like strings); the agent adapter bounds packageId/version at the source; ReducerVerifier replays decode-fold-discard (head-signal check before the journal so stubbed streams stay silent, mid-stream truncation and count-imbalance semantics unchanged, pinned by tests); ISignalRepository.QueryBySessionAsync carries a cumulative PayloadJson budget (SignalQueryLimits, 32 M chars) reflected in the truncated flag of sessions/{id}/signals and reducer-verification; no ingest cap — stored signals stay verbatim
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:06:51 +02:00
Oliver Kieselbach 3c8ef02191 feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:02:39 +02:00
Oliver Kieselbach 251e82a45a fix(mcp): tool-call logging never records tenant-config secret values — withToolTelemetry/summarizeArgs gain a per-argument ArgPolicy ('keys' | 'drop') declared at the call site, update_tenant_config logs only the NAMES of args.fields (webhook/SAS URLs and custom headers previously went verbatim, up to 200 chars, into the container log stream under the IaC-default MCP_TOOL_LOGGING=true, readable by log consumers below Global Admin); values remain in the GA-gated backup snapshot and audit log. Backend: TenantConfigPatchService no longer returns the Newtonsoft ex.Message on a JSON type mismatch (it quotes the offending value, so a secret sent into a numeric field echoed into the tool response and log line) — only the field path is named. Tests for keys-only/drop rendering, log-line content on success and soft-error paths, and the backend error text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:58:23 +02:00
Oliver Kieselbach 01311898a8 fix(ci): no third-party action holds release/deploy credentials by mutable tag — build-agent creates releases with first-party gh release create (job holds contents/id-token/attestations:write), combine-rules pushes generated outputs with plain git (contents:write on main), deploy-web builds the static export in CI (setup-node 22, npm ci, next build — same path ci.yml tests) and uploads it with the exactly pinned SWA CLI 2.0.10 instead of Azure/static-web-apps-deploy (Oryx in-container build, deploy token as action input), attests portal-<sha>.tar.gz and drops the unusable per-PR preview environments; azure/login and Azure/functions-action are SHA-pinned with version comments for Dependabot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:19 +02:00
Oliver Kieselbach a5bca8a730 fix(web): CSV exports neutralize spreadsheet formula triggers in device-controlled cells (CWE-1236) — generateCsvExport/generateSessionCsvExport/generateRuleResultsCsvExport only doubled quotes, so an event Message, EventType, Source, DataJson, DeviceName, SerialNumber, FailureReason or rule explanation starting with =, +, -, @, tab or CR was evaluated as a formula (HYPERLINK/WEBSERVICE exfiltration, DDE) when a tenant admin opened the exported CSV or the operator opened the problem-report attachment; shared csvCell() now prefixes such cells with an apostrophe in addition to quoting, with regression tests for the DDE/HYPERLINK payloads and all three exporters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:54:40 +02:00
Oliver Kieselbach 87cb1f1cea fix(backend): mcp-usage/user/{oid} filters usage rows to the caller's tenant and never signals foreign existence — UsageCrossTenantGuard skipped rows with an empty TenantId (legacy rows without the column, tokens without a resolvable tid), so a self-service tenant admin of any tenant could read another tenant's user usage (UPN, endpoints/tool names, per-day counts) by oid; and the distinct 403 CrossTenantAccessDenied vs 200-empty made the route a cross-tenant user-existence oracle; FilterForCaller now returns only rows whose TenantId equals the caller's tid for non-global callers (empty TenantId or empty caller tid ⇒ nothing), the handler always answers 200 with the filtered set (foreign oid ≡ unknown oid), dropped rows are logged as an ops warning only; tests rewritten for the filter semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:38:16 +02:00
403 changed files with 48058 additions and 4679 deletions
+30 -11
View File
@@ -324,13 +324,19 @@ jobs:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
# First-party `gh` instead of a third-party action: this job holds contents:write,
# id-token:write and attestations:write, so no code we do not control runs here.
# --generate-notes appends the auto-generated notes below the body.
- name: Create GitHub Release
if: inputs.create_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
body: |
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_BODY: |
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
### Installation
@@ -355,9 +361,22 @@ jobs:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
draft: false
prerelease: ${{ inputs.prerelease }}
files: |
${{ steps.artifact.outputs.versioned_zip_release }}
${{ env.MANIFEST_NAME }}
generate_release_notes: true
run: |
$ErrorActionPreference = 'Stop'
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -Path $notesFile -Value $env:RELEASE_BODY -Encoding utf8 -NoNewline
$ghArgs = @(
'release', 'create', $env:RELEASE_TAG,
$env:RELEASE_ZIP, $env:MANIFEST_NAME,
'--repo', $env:GITHUB_REPOSITORY,
'--target', $env:GITHUB_SHA,
'--title', $env:RELEASE_NAME,
'--notes-file', $notesFile,
'--generate-notes'
)
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
& gh @ghArgs
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
Write-Host "Release $env:RELEASE_TAG created"
+26
View File
@@ -143,3 +143,29 @@ jobs:
git diff --stat
exit 1
fi
shared-manifests-in-sync:
name: Generated wire types match shared-manifests.json
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
# JSON ↔ C# parity is the dotnet job's SharedManifestParityTests; this job pins
# JSON ↔ generated TS (shared-manifests.generated.ts, wire-types.generated.ts and the
# MCP's wire-types + wire-vocabularies copies - the vocabularies are what the MCP
# tool input schemas derive their z.enum() from).
- name: Regenerate
run: node scripts/generate-shared-manifest-types.js
working-directory: src/Web/autopilot-monitor-web
- 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
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
+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.3
uses: github/codeql-action/init@v4.37.9
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -68,6 +68,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v4.37.3
uses: github/codeql-action/analyze@v4.37.9
with:
category: /language:${{ matrix.language }}
+18 -6
View File
@@ -63,11 +63,23 @@ jobs:
- name: Combine rules into dist/
run: node rules/scripts/combine.js
# Plain git instead of a third-party action: this step runs with contents:write on
# main, so no code we do not control gets to push. Checkout leaves the job's
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
- name: Commit generated outputs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
# combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
run: |
set -euo pipefail
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/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
if git diff --cached --quiet; then
echo "Generated outputs unchanged - nothing to commit"
exit 0
fi
git commit -m "chore: regenerate rules/dist + generated TS from individual files"
git push origin HEAD:main
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v7
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -65,7 +65,7 @@ jobs:
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
with:
app-name: ${{ env.FUNCTION_APP }}
package: ./publish
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+75 -60
View File
@@ -1,5 +1,13 @@
name: Deploy Web (Static Web App)
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
on:
push:
branches:
@@ -8,39 +16,44 @@ on:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
permissions:
contents: read
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
env:
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Surface it as an env var so the deploy step can gate on it — secrets
# cannot be referenced directly in an `if:` expression.
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Build And Deploy
id: builddeploy
# Skip (green) instead of failing when the token is missing (Dependabot
# PRs); real deploys on main and internal PRs still have it.
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
- name: Install dependencies
working-directory: ${{ env.WEB_DIR }}
run: npm ci
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
- name: Build
working-directory: ${{ env.WEB_DIR }}
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
@@ -55,28 +68,50 @@ jobs:
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
run: |
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
# The SWA action reporting success proves the upload, not that the production host
# serves the new build — verify against the live /version.json (Cache-Control:
# no-store via staticwebapp.config.json) before publishing the version manifest.
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
id: package
run: |
archive="portal-${GITHUB_SHA::7}.tar.gz"
tar -C "$WEB_DIR/out" -czf "$archive" .
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.package.outputs.archive }}
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
# Upload only -- out/ is already built. --swa-config-location points at the app root,
# where staticwebapp.config.json lives (next export does not copy it into out/).
- name: Deploy to Static Web App
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
--env production \
--no-use-keychain
# The CLI reporting success proves the upload, not that the production host serves the
# new build -- verify against the live /version.json (Cache-Control: no-store via
# staticwebapp.config.json) before publishing the version manifest.
- name: Verify deployed portal build is live
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
@@ -100,10 +135,9 @@ jobs:
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component deploy record, written last and only on a verified deploy —
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json.
- name: Publish version manifest
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$manifest = [ordered]@{
@@ -119,22 +153,3 @@ jobs:
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
Write-Host $manifest
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- name: Close Pull Request
id: closepullrequest
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
action: "close"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: Federated login + read check
steps:
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+17 -1
View File
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
timestamp: 2026-08-30
timestamp: 2026-09-01
---
# Problem
@@ -139,6 +139,22 @@ regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
flip-backs). Kill switch: app setting `CmTraceSkewTripwireDisabled`. Goal state: the event
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
Samples are windowed to the session's most recent INGEST ERA before any of that is
computed: the batch stamps of both sides are walked backwards from the newest one and the
era ends at the first `ReceivedAt` gap wider than 2 h. A session partition is not one agent
run — a pre-provisioning session's Part 1 is written by whatever agent build was current
weeks earlier, and its events stay in the partition forever. Field 2026-09-01 (sessions
`e797117b` / `c06d639d` / `d7c8032b`, one tenant): 26 IME samples at exactly −60 min from a
2026-08-20 technician leg under agent 2.0.1409 — before per-line anchoring — outnumbered the
3…9 clean samples of the user leg that completed that morning and fired the tripwire against
2.0.1445, a build the device had self-updated to minutes earlier. The skew was real, 12 days
old, and already fixed; no scan-wide statistic can tell those apart, because event rows carry
no agent version. The era boundary can. `eraStartUtc` and the excluded per-side sample counts
travel in the ops event, so an inherited leg stays visible without being alarmed on. The
2 h threshold sits above any in-leg upload gap (a live agent uploads self-metrics every few
minutes, reports `session_stalled` at 60 min idle, and the maintenance sweep classifies
silence at 2 h) and below every pre-provisioning handover.
Device-clock problems (the customer-actionable cousin) are covered separately by the
`clock_skew` analyze condition behind `ANALYZE-DEV-008`, which excludes IME-derived events
precisely so an anchoring regression can never surface as a customer finding.
+39 -1
View File
@@ -8,7 +8,7 @@ tags:
- decision-engine
- reducer
- completion
timestamp: 2026-07-11T00:00:00+02:00
timestamp: 2026-08-30T00:00:00+02:00
---
# V2 Agent — Decision Engine (DecisionCore)
@@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
**Self-update observation** (gktatooine session 946ccbd6): the version on
`RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
often replaces the MSI-shipped binary on its first start — minutes after detection,
before the deployment begins. `RealmJoinWatcher` therefore arms a
`RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
registry write of the updater; appearance-watch on the `Uninstall` parent when the
entry does not exist yet) once Detected fired, re-reads the binary version after a
2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
posts the typed `RealmJoinAutoUpdateDetected` signal plus the
`realmjoin_autoupdate_detected` timeline event when the version differs from the
baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
(`WithVersionOverride` — the one legitimate later write past the set-once helpers);
it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
baseline at detection is seeded silently by the first successful re-read.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
@@ -208,6 +222,15 @@ IME false-positive AccountSetup entries.
- **No silent parking**: a non-terminal post-AccountSetup session must always hold a
resolution-capable deadline; a 10-min dwell tripwire emits
`session_parked_without_deadline`.
- **Every payload-derived string stored into state is bounded** (`FactStringBounds`,
256 chars): RealmJoin package id/version/scope (scope normalised to `machine`/`user`),
failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`,
RealmJoin product version/channel. The bound is applied *before* any dedupe comparison
so a forged multi-hundred-KB key neither costs a full-length compare per stored row nor
lands in snapshots/audit trails. It never truncates real telemetry — the sources are
registry key names (Windows caps them at 255), GUIDs and enum-like strings. The same
engine replays device-uploaded signals server-side (`ReducerVerifier`), which is why
the bound lives in the reducer, not in the agent adapter alone.
# Hosting & threading (inside the agent)
@@ -228,6 +251,21 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
one-shot terminal-stage hook (raises the agent's `Terminated` event off-thread).
- The `DeadlineScheduler`'s `Fired` event loops back in as synthetic `DeadlineFired`
signals.
- **Pipeline health in `agent_metrics_snapshot`** (2026-09-01, motivated by the WG-seal
soak in which a frozen engine and a deliberate Weak sealing verdict were telemetrically
indistinguishable): every snapshot carries monotonic totals from `SignalIngress`
(`signal_last_ordinal`, `signal_processed_total`, `signal_pending_count`,
`signal_reentrant_total`, `signal_queue_peak` — a monotonic peak, deliberately no
momentary queue length, so snapshot sampling cannot alias it) plus `decision_stage`,
`decision_last_applied_ordinal` and — once the sealing classifier has produced a
verdict — `wg_sealing_level`/`wg_sealing_score` from the immutable `DecisionState`.
Two snapshots therefore prove whether the worker ran in between: a frozen worker keeps
`signal_pending_count` growing while the processed/applied counters stand still. The
probe chain is read-only (`EnrollmentOrchestrator` → `IComponentFactory.
CreateCollectorHosts(decisionStateProbe)` → `PeriodicCollectorLifecycleHost` →
`AgentSelfMetricsCollector`); the snapshot event itself travels the collector/event
path, which is independent of the signal pipeline it measures — field-proven by the
freeze cases, whose perf/metrics events kept flowing while the engine stood still.
- On restart, `RecoveryCoordinator` folds the persisted signal log through a transient
`new DecisionEngine()` before the live pipeline starts — see
[logs & persistence](logs-and-persistence.md).
@@ -39,7 +39,9 @@ Every table in `Constants.TableNames` must belong to at least one deletion lifec
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
The lookup row is **claimed at registration, first-writer-wins** (`ClaimSessionTenantLookupAsync`, create-only `AddEntity` BEFORE any Sessions/SessionsIndex write): on 409 the existing owner is read back — same tenant ⇒ normal re-registration, different tenant ⇒ `SessionTenantConflictException` ⇒ `409 Conflict` with `ErrorCode=session_owner_mismatch` (owning tenant not disclosed) plus a `SessionTenantConflict` ops event, and nothing is written. The claim is deliberately NOT fail-soft. Before this, the row was an unconditional last-writer-wins upsert after the session write, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-`tenantId` Global Admin/Reader read — and GA writes such as the `globaladmin` annotation lane — silently served from a forged session. Session ids are 122-bit random, so a genuine cross-tenant collision does not occur; a conflict is an attack or an agent bug and fails loudly. The legacy self-heal write is Add-only too (an existing claim is the authority), and `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the pre-table window once (idempotent, Add-only) so no legacy session id is claimable. The row is deleted by the cascade one step before the tombstone (no tombstone is kept — a freed id gives a foreign tenant nothing a fresh GUID would not) and is rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
`requireGlobalAdmin: true` additionally corroborates the resolved tenant against its Sessions row (one point-read): a mapping whose session does not exist in the resolved tenant keeps `TargetTenantId`, so a stale or poisoned row can never steer a GA write — silently, without any operator confirmation step (a "confirm tenant GUID" dialog was rejected as unusable). Read paths keep the single point-read; their own session read is the corroboration (404).
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
+134
View File
@@ -0,0 +1,134 @@
---
type: Concept
title: Ops-Events Query Surface
description: The server-side filter surface of GET /api/global/ops-events (category, eventType, severity/minSeverity, tenant drill, date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set, and why the paged cross-category read has to fan out over the complete category vocabulary.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
tags:
- backend
- table-storage
- ops-events
- pagination
- mcp
timestamp: 2026-09-01T00:00:00+02:00
---
# Problem
`OpsEvents` is the platform operator's log: consent flows, maintenance runs, security blocks,
tenant offboards, agent emergency breaks, SLA and Azure Monitor alerts. It is partitioned by
**category** (`PartitionKey`), with a reverse-tick `RowKey` for newest-first ordering.
Until 2026-09-01 the read endpoint could only narrow by category and date window. Every other
question — *"did any `AgentEmergencyBreak` fire yesterday?"*, *"show me everything at Error or
worse"* — had to be answered by pulling the whole category and filtering it on the client. For the
MCP tool that client is a language model, so an event-type question cost a category-sized response
in model context.
# Schema
`GET /api/global/ops-events` (GA + Global Reader; `TenantScoping.None`, so a delegated admin cannot
reach it at all):
| Param | Where it is applied | Notes |
| --- | --- | --- |
| `category` | `PartitionKey eq` — indexed | One partition; omit to fan out over all of them |
| `eventType` | `EventType eq` — server-side scan | **Exact and case-sensitive**; Table Storage cannot fold case |
| `severity` | `Severity eq` — server-side scan | Normalised onto the canonical vocabulary before use |
| `minSeverity` | OR-set over `Severity` — server-side scan | Threshold: that level and everything above |
| `dateFrom` / `dateTo` | RowKey range — indexed | Both absent ⇒ backend resolves the last 30 days |
| `tenantId` | **in-memory**, post-fetch | Drill, not an isolation boundary — see below |
| `pageSize` / `continuation` | — | `pageSize` opts into pagination; absent ⇒ full window, no cap |
The filter surface lives in two shared pieces so no path can honour a different one:
`OpsEventFilterRequest` (query → `OpsEventQueryFilters` + pagination extras) and
`TableOpsEventRepository.AppendFieldFilters` (filters → OData clauses), which **both** filter
builders call — the single-category/unpaged builder and the per-partition fan-out builder that adds
the `RowKey gt` bound. This mirrors `AuditLogFilterRequest` / `AuditLogQueryFilters` on the audit
endpoints one-for-one.
## Why `minSeverity` is an OR-set, not a range
Table Storage compares strings lexicographically, and the severity names sort
`Critical < Error < Info < Warning` — nothing to do with severity order. A `Severity ge 'Warning'`
clause would return `Warning` only, silently dropping `Error` and `Critical`, which is precisely the
alert-worthy half. `minSeverity` therefore expands to
`(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')`. The ladder itself lives
in `OpsEventSeverity.Rank` and is shared with `OpsAlertDispatchService`, so *"Warning and above"* in
a read means exactly what *"Warning and above"* meant when the alert rules decided to fire.
`minSeverity=Info` emits no clause at all — it is the floor, so the clause would only cost query
length.
## Why severity is normalised but eventType is not
Both columns are matched with `eq`, which is case-sensitive. Severity has a closed vocabulary of
four values, so `?severity=warning` is normalised to `Warning`, and anything outside the vocabulary
is a **400** — an unnormalised value would return an empty page that reads exactly like *"no such
events happened"*.
Event types have a declared vocabulary too (`OpsEventTypes`, reflected into the shared manifest and
served to the model as `get_resource(name="ops_event_types")`), but the filter deliberately does
**not** validate against it: stored rows outlive the vocabulary, so a type that was retired after a
refactor must stay searchable in the historical window. The value is passed verbatim and a typo
legitimately returns zero rows — the catalog is there so nobody has to guess.
## Continuation fingerprint
Every filter value is appended to the pagination *extras*, which are both folded into the
continuation-token fingerprint and echoed on `nextLink`. Consequences:
* a token minted for one filter cannot page a different one (it is rejected with a 400),
* the follow-up request is self-contained — the resolved date window and the filters travel on the
link, so no page re-resolves "now",
* extras are appended **after** the pre-existing `category` / `tenantId` discriminators and skipped
when empty, so tokens minted before the field filters existed still fingerprint identically.
Because severity values are normalised *before* fingerprinting, `?severity=error` and
`?severity=Error` share one token instead of minting two that each reject the other.
## The tenant drill stays in memory
`?tenantId=` is filtered post-fetch (`GetOpsEventsFunction.FilterByTenant`) because OpsEvents is
partitioned by category, and — deliberately — because the in-memory comparison is
`OrdinalIgnoreCase` while an OData `eq` would be case-sensitive: stored tenant casing is not
guaranteed, so pushing this one down would lose rows. It is drill *correctness*, not isolation:
every caller that reaches this route already has full cross-tenant scope. Pages therefore can report
fewer items than `pageSize` when the drill is narrow.
## The category vocabulary must be complete
The paged cross-category read does not issue one cross-partition query (Azure would page it by
`PartitionKey asc`, so the first page would come entirely from the alphabetically first category).
It fans out per partition over a fixed list and merge-sorts. That list is therefore load-bearing: a
category that is written but not listed is **invisible to every paged reader** while the unpaged
full-table path still returns it. `Platform` (Azure Monitor alerts relayed through the ops alert
webhook) was missing that way from its introduction until 2026-09-01.
The list is now owned by `OpsEventCategory.All`, and `OpsEventCategoryCoverageTests` reflects over
the constants to fail the build when a new category is not added to it.
# Examples
```
# One event type, last 24h, cross-category
GET /api/global/ops-events?eventType=AgentEmergencyBreak&dateFrom=2026-08-31T12:00:00Z&pageSize=200
# Everything alert-worthy in the maintenance partition this month
GET /api/global/ops-events?category=Maintenance&minSeverity=Error&dateFrom=2026-08-01T00:00:00Z
```
MCP `get_ops_events` exposes the same surface plus a `days` shorthand, which it resolves to a
concrete `dateFrom` **client-side** — a backend-side "last N days" would re-resolve "now" on every
page and blow the token fingerprint. An explicit `dateFrom`/`dateTo` always wins over the shorthand.
# Citations
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs`
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs`
* `/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs`
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs`
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` — the declared event-type vocabulary
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs`
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs`
* `/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts`
* [Business Timestamps](business-timestamps.md) — why the date window filters on the RowKey
+151
View File
@@ -0,0 +1,151 @@
---
type: Concept
title: Ops Notification Channels
description: How platform alerts are routed to named channels instead of three fixed provider slots — the shared NotificationChannel model on both the tenant and platform side, one NotificationChannelDispatcher that routes each channel to its transport, the legacy-slot synthesis that keeps pre-migration dispatch behaviour identical, the empty-means-all rule binding, why Telegram is Global-Admin-only by construction, and why an ops event's structured payload only reaches a destination when a rule opts in.
resource: /src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs
tags:
- backend
- notifications
- ops-events
- configuration
- security
timestamp: 2026-09-01T00:00:00+02:00
---
# Problem
Platform (ops) alerting had no routing. An `OpsAlertRule` was
`{ EventType, MinSeverity, Enabled }` with no destination, and `OpsAlertDispatchService`
broadcast every matching rule to all three hard-wired slots in `AdminConfiguration`
(`OpsAlertTelegramChatId`, `OpsAlertTeamsWebhookUrl`, `OpsAlertSlackWebhookUrl`). Three
consequences followed:
1. **No per-event destinations.** Binding a conversion signal to a sales channel meant every
other rule reached that channel too.
2. **Only the first matching rule was evaluated** (`FirstOrDefault`), so two rules on the same
event type could not coexist.
3. **The payload never left the backend.** `OpsEventService.WriteAsync` serialized a `details`
object into the `OpsEvents` table but passed only `(category, eventType, severity, message,
tenantId)` to the dispatch — a receiving system saw a tenant GUID and a sentence.
The tenant side already had the right model: `NotificationChannel` (a named list in
`TenantConfiguration.NotificationChannelsJson`) with analyze rules binding channels by id
(`RuleState.NotifyChannelIdsJson`). The platform side needed that model, not a second one.
# Schema
## One model, two scopes
`NotificationChannel` is unchanged and now backs both lists:
| Scope | Storage | Bound by |
| --- | --- | --- |
| Tenant | `TenantConfiguration.NotificationChannelsJson` | per-channel `NotifyOn*` toggles + analyze rules (`NotifyChannelIdsJson`) |
| Platform | `AdminConfiguration.OpsNotificationChannelsJson` | `OpsAlertRule.NotifyChannelIds` |
The scopes stay separate on purpose — platform events resolve against platform channels, and a
tenant's channel list can never receive an ops alert. What is shared is the *model* and the
*dispatch path*, so a new provider is added once.
## One dispatch path
`NotificationChannelDispatcher` is the channel-level send API. It routes by provider:
`WebhookProviderType.Telegram` goes to `TelegramNotificationService`; everything else is a
rendered webhook POST through `WebhookNotificationService`. Every caller that sends to
configured channels — enrollment (`EventIngestProcessor`), session start
(`RegisterSessionFunction`), hardware rejection (`ReportDistressFunction`), SLA
(`SlaBreachEvaluationService`), analyze rules (`AnalyzeOnEnrollmentEndHandler`), ops alerts
(`OpsAlertDispatchService`) and both test endpoints — goes through it.
## Telegram is Global-Admin-only by construction
Telegram (`WebhookProviderType.Telegram = 40`) is the odd provider: it is not a webhook. The
channel's `Url` carries the destination **chat ID**, and the bot token is platform-owned
(PreviewConfig `WebhookUrl`). A tenant admin configuring one would be sending through *our*
bot, which is why the gate is a server-side rule, not a hidden dropdown option:
* `TenantConfigValidation.ValidateTelegramChannelGate` refuses a non-GA caller who **adds** a
Telegram channel or changes an existing one's destination or enabled state. It lives in
`ValidateModel`, so it covers both write paths — the full-model PUT *and* the MCP field-patch
flow (`TenantConfigPatchService`). A check placed in `UpdateTenantConfigurationFunction` alone
would not.
* An **unchanged** GA-created channel passes, so a tenant admin can still save unrelated config.
* Telegram destinations skip `SsrfGuard.ValidateWebhookUrlFormat` (a chat ID is not a URL and
would always fail it) and are format-checked as a numeric id or `@username` instead.
## Migration without a data job
`AdminConfiguration.GetOpsNotificationChannels()` returns the stored list, or — when it is empty —
synthesizes one channel per *configured* legacy slot with stable ids (`legacy-telegram`,
`legacy-teams`, `legacy-slack`) and the legacy enabled flags. `GetAdminConfigurationFunction`
projects the same synthesis into the read model, so the editor shows exactly what receives
alerts today and the first save materializes it. The legacy fields stay as the migration source
and are no longer written by the Alerts section.
## Rule binding: empty means all
`OpsAlertRule.NotifyChannelIds` null or empty = every enabled channel. That is what every rule
written before routing existed carries, so behaviour on the deploy day is identical. Dispatch
resolves **all** matching rules (`Where`, not `FirstOrDefault`) and unions their channels;
ids that no longer resolve are dropped rather than falling back to broadcast — a rule whose only
channel was deleted must reach nothing, not everything. The web editor mirrors the same rule in
`app/admin/components/opsChannelRouting.ts`.
## Payload delivery is opt-in per rule
`WriteAsync` hands the serialized `details` to `DispatchAsync`, but a channel only receives it when
a rule sets `OpsAlertRule.IncludePayload`. **Default false**: an ops alert is a "something happened,
go look" signal, most payloads are operational noise in a chat, and some carry data the baseline
never does (tenant domain, administrator contact address). Widening what leaves the platform stays
a deliberate per-rule decision.
`ResolveTargets` therefore returns two groups. Channels reached only by plain rules get an alert
built with `detailsJson: null`; channels reached by an opted-in rule get the enriched one. A channel
in both groups appears in the payload group only — one message per channel, and an unrelated
sibling rule cannot cancel an explicit opt-in.
For the enriched alert, `BuildAlert`:
* flattens top-level **scalars** into `NotificationFact`s — capped at 12 facts and 256 characters,
nulls and nested values skipped — so card formats and the plain-text Telegram message carry the
same information,
* passes the raw JSON through `NotificationAlert.DataJson`, which only `GenericJsonRenderer`
emits, as a real JSON object under `data`. Additive within `schemaVersion` 1.0.
`NotificationAlert.EventType` is set either way: it is the same value the `Event` fact already
carries, so it adds no information — only a stable key for a generic consumer to branch on.
# Examples
A trial-conversion signal reaching only a sales webhook:
1. Admin → Alerts → add a channel `Sales` (Generic JSON, the internal endpoint).
2. Enable the `TenantTrialStarted` rule at `Info`, select **only** `Sales`, and switch its
**Payload** toggle on (off by default, so every other rule keeps the baseline alert).
3. `PlanManagementFunction` fires `TenantTrialStarted` from both plan write paths — the
self-service `POST config/{tenantId}/trial` and a GA grant via `PATCH config/{tenantId}/plan`
(distinguished by `selfService`). Ending a trial is a downgrade and stays with
`TenantPlanDowngraded`.
4. The webhook receives `eventType`, the rendered facts, and `data` with `domainName`,
`contactEmail`, `trialStartedUtc`, `trialExpiresUtc`, `grantedBy`, `selfService`. The operator
push channel receives nothing, because it is not bound to that rule.
# Gotchas
* **A new ops event type must be dual-registered** in `OPS_EVENT_TYPES`
(`app/admin/components/OpsAlertRulesSection.tsx`) or it cannot be selected. `OpsEventTypeDualRegisterTests`
enforces it.
* **`OpsNotificationChannelsJson` is secret-bearing** and is on the deny-list in
`AdminConfiguration.RedactedCopyForReader()`. `NotificationChannel.RedactList` /
`RestoreRedactedList` are shared with the tenant list — do not add a second copy.
* **Details are capped at 4096 characters** in the `OpsEvents` table (`TableOpsEventRepository`)
before they ever reach a channel.
# Citations
* `/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs`
* `/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs`
* `/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs`
* `/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs`
* `/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs`
* `/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs`
+97 -11
View File
@@ -1,14 +1,14 @@
---
type: Concept
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
title: Drift Guards — SignalR Names, RowKeys, Wire DTOs, Tool Vocabularies, Config Sections, Scoped Routing
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, the MCP's generated tool vocabularies with their inline-enum ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
resource: /src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs
tags:
- contracts
- guard-tests
- signalr
- web
timestamp: 2026-08-13T00:00:00+02:00
timestamp: 2026-08-31T00:00:00+02:00
---
# Drift Guards
@@ -49,13 +49,94 @@ restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic surviv
solely as the fallback for manifests written before the field existed and must outlive
one full manifest-retention cycle.
## Anonymous OkAsync responses — frozen, shrink-only
## Wire DTOs — generated interfaces, zero anonymous success bodies
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
sites as a per-file baseline. A new anonymous response (new file or count above
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
list stays truthful and only ever shrinks.
Every HTTP success body is a flat class in `AutopilotMonitor.Shared.Models`
implementing the `IApiResponse` marker (2026-08-31, `feat/typed-api-contract` —
migrated from the 44 `OkAsync(new { … })` + 134 raw `WriteAsJsonAsync(new { … })`
sites the 2026-08-13 audit froze). Three layers enforce it:
- **Compile time**: the only `OkAsync/CreatedAsync/JsonAsync` overloads constrain on
`IApiResponse` — an anonymous object cannot satisfy the constraint. Error bodies
(first key `error`/`message`, or literal `success = false`) stay anonymous by design.
- **`TypedResponseGuardTests`** (successor of `OkAsyncBaselineGuardTests`): both
per-file baselines are EMPTY — any anonymous success body, through the helpers or
raw `WriteAsJsonAsync`, is a straight failure. A reflection fact keeps every
`IApiResponse` implementer flat (System.Text.Json serializes derived properties
before base ones — key order is wire contract; MCP hands raw JSON to an LLM) and
inside the Shared assembly.
- **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal
vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case
per key that WhenWritingNull omits.
- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over
the reachable wire-type graph fails on any `object` / collection-of-`object`
property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`,
heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt.
The former deliberately-untyped slots (RuleDryRun trace, HealthCheck,
GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports,
MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw
table entities left the wire entirely in the same pass: global-admins,
tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` /
`TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT
partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON
shape tests instead of parity facts.
The DTO rules: declaration order == wire order; a property is nullable exactly when a
site can emit null (the key then vanishes — never add non-null defaults that would
invent a key); preformatted strings (`.ToString("o")`, enum `.ToString()`) stay
`string`; `fields=` projections stay `IReadOnlyList<object>` tagged
`[ProjectedItems(typeof(Item))]`; raw table rows stay verbatim-keyed dictionaries
(no `DictionaryKeyPolicy` — dictionary KEYS never run through the camelCase policy,
and dictionary VALUES keep explicit nulls, both pinned by tests).
`SharedManifestParityTests` (schemaVersion 2) reflects the full graph — every
`IApiResponse` implementer plus `[WireContract]`-marked payload types, transitively
closed by `WireTypeManifestBuilder` — into the `types` section of
`shared-manifests.json`, carrying C# `<summary>` texts. The web codegen
(`npm run generate:manifests`) emits `utils/wire-types.generated.ts` (one interface
per object, one string union per enum, JSDoc from the summaries); the hand-written
`types/session.ts` / `types/enrollment.ts` / `types/adminConfig.ts` re-export from
it. Freshness is pinned by the vitest suite and the `shared-manifests-in-sync` CI
job. Unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the
manifest build instead of degrading to `unknown`.
## Tool vocabularies — the MCP derives them, it no longer retypes them
Wire TYPES reached the MCP from day one (`src/generated/wire-types.generated.ts`, the
same codegen output the web gets). Vocabularies did not: types are erased at runtime and
a `z.enum([...])` needs values, so every MCP tool schema hand-typed its own copy of
statuses, severities and categories — with nothing to compare against. The copies drifted
exactly as you would expect: `search_sessions` offered 5 of 8 session statuses (no
`AwaitingUser`, no `Incomplete`), both event readers offered 4 of 6 severities (no `Debug`,
no `Trace`), and `get_ops_events` named 6 ops categories while the backend wrote 7. None of
that surfaces as an error — the tool just quietly cannot ask for the missing value.
The vocabularies now ship as VALUES. `MCP_VOCABULARIES` in the codegen maps manifest
sections onto `src/generated/wire-vocabularies.generated.ts` (`export const
SESSION_STATUSES = [...] as const` plus its union type); the tool schemas do
`z.enum(SESSION_STATUSES)` and descriptions interpolate the list instead of spelling it.
Freshness is pinned twice: `wire-types-freshness.test.ts` compares the committed file
byte-for-byte against a fresh codegen run, and the `shared-manifests-in-sync` CI job
diffs it after regenerating.
`vocabulary-drift.guard.test.ts` is the ratchet that keeps it that way: every inline
`z.enum([...])` left in `src/tools/*.ts` must be on a baseline that names why it has no
backend owner (MCP-local knobs like `depth: fast|deep`, plus three free-string columns —
rule type, connection type, CVE risk — that have no C# constants class yet). A new
hand-typed list fails until it is derived or reviewed onto the baseline, and a stale
baseline entry fails too.
Ops event types got the missing owner: they were bare literals at the `OpsEventService`
call sites, so nothing could enumerate them and both the portal alert-rule catalog and the
MCP had to retype the list. `OpsEventTypes` (Shared) now declares all 77; the write sites
use the constants (a raw literal fails `OpsEventTypeDualRegisterTests`), the manifest
exports them, and `get_resource(name="ops_event_types")` serves categories + severities +
types to the model from the generated copy.
Backend-internal lists follow the same rule: a list that enumerates a constants class IS
the class. `TableOpsEventRepository.AllCategories` is `OpsEventCategory.All` rather than a
retyped copy — the copy had gone stale and silently hid every `Platform` (Azure Monitor)
event from the paged cross-category read while the unpaged path still showed it.
## Tenant settings — per-section PATCH
@@ -98,9 +179,14 @@ list in `sectionFieldMap.test.ts`.
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
- `src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js` — `MCP_VOCABULARIES`, the vocabulary codegen
- `src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts` — the inline-enum ratchet
- `src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` + `OpsEventTypeDualRegisterTests.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
- `src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs` + `WireTypeManifestBuilder.cs` + the `*WireParityTests` files
- `src/Shared/AutopilotMonitor.Shared/Models/CommonApiModels.cs` — `IApiResponse`, `[WireContract]`, `[ProjectedItems]`
- `src/Web/autopilot-monitor-web/utils/wire-types.generated.ts` (via `scripts/generate-shared-manifest-types.js`)
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
+3 -1
View File
@@ -15,7 +15,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs (IApiResponse + generated wire-types.generated.ts, empty anonymous-body baselines), the MCP's generated tool vocabularies (wire-vocabularies.generated.ts + the inline-z.enum ratchet), per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
# Agent
@@ -54,6 +54,8 @@ https://docs.autopilotmonitor.com (separate repository).
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [Ops-Events Query Surface](backend/ops-events-query-surface.md) - The server-side filter surface of the ops-events read (category, eventType, severity/minSeverity, in-memory tenant drill, RowKey date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set instead of a lexicographic range, and why the paged cross-category fan-out must cover the complete category vocabulary (Platform was missing).
* [Ops Notification Channels](backend/ops-notification-channels.md) - Platform alerts route to NAMED channels instead of three fixed provider slots: the tenant-side NotificationChannel model now backs both scopes, one NotificationChannelDispatcher routes each channel to its transport (Telegram is not a webhook - chat id, platform-owned bot, Global-Admin-only enforced in ValidateModel so the MCP patch path is covered too), legacy slots are synthesized with stable ids so pre-migration dispatch is unchanged, a rule's empty channel binding means all, and an ops event's structured details reach the destination as facts plus a `data` object only when a rule opts in (IncludePayload, off by default, so the baseline alert is unchanged).
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
+44
View File
@@ -1,5 +1,49 @@
# Log
## 2026-09-01 (3)
* **Update**: `agent/decision-engine.md` - agent_metrics_snapshot gains a signal-pipeline health block: monotonic SignalIngress totals (last ordinal, processed, pending, reentrant, queue peak) + decision_stage / decision_last_applied_ordinal / wg_sealing_level+score from the immutable DecisionState, wired via a read-only decisionStateProbe through IComponentFactory. Motivated by the WG-seal soak (project_wg_seal_postfix_soak_residual): a frozen engine and a deliberate Weak sealing verdict were previously indistinguishable in telemetry.
## 2026-09-01 (4)
* **Update**: `drift-guards.md` — MCP tool vocabularies are generated, not retyped. The codegen now emits `wire-vocabularies.generated.ts` (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) alongside the MCP's wire types, byte-pinned by the MCP freshness suite and the `shared-manifests-in-sync` CI job; the tool schemas derive their `z.enum()` from it. Found by the switch: `search_sessions` had offered 5 of 8 session statuses (no `AwaitingUser`/`Incomplete`) and both event readers 4 of 6 severities (no `Debug`/`Trace`). `vocabulary-drift.guard.test.ts` ratchets the remaining inline enums against a reasoned baseline. New `OpsEventTypes` (Shared) gives the 77 ops event types a canonical home — `OpsEventService` write sites use the constants (a raw literal now fails the dual-register test), and `get_resource(name="ops_event_types")` serves the vocabulary to the model.
## 2026-09-01 (3)
* **New**: `backend/ops-notification-channels.md` - platform alerting gains named channels. `AdminConfiguration.OpsNotificationChannelsJson` reuses the tenant `NotificationChannel` model (redaction/restore helpers hoisted onto the model so both scopes share one implementation); `OpsAlertRule.NotifyChannelIds` binds rules to channels with empty = all, and dispatch now evaluates ALL matching rules (`Where`, not `FirstOrDefault`) and unions their targets, dropping ids that no longer resolve instead of falling back to broadcast. New `NotificationChannelDispatcher` is the single channel-level send API for every caller (enrollment, session start, hardware rejection, SLA, analyze rules, ops alerts, both test endpoints). New provider `WebhookProviderType.Telegram = 40` carries a chat id rather than a URL and sends through the platform-owned bot - Global-Admin-only, enforced in `TenantConfigValidation.ValidateModel` so the MCP field-patch path is gated too, not just the PUT. Legacy provider slots are synthesized into channels with stable ids (`legacy-telegram` named "Autopilot Monitor IT Engineer") and projected into the GET read model, so day-one behaviour is unchanged and the first save materializes the list. New ops event `TenantTrialStarted` fires from both plan write paths, and `OpsEventService.WriteAsync` hands the serialized details to the dispatch - but delivery is opt-in per rule (`OpsAlertRule.IncludePayload`, default false), so every existing alert keeps its category/event/severity/tenant baseline and the trust-page description of operator alerting stays accurate. `ResolveTargets` splits targets into a payload group and a plain group (a channel in both gets the payload once); for the enriched alert `BuildAlert` flattens top-level scalars into facts (12 / 256 chars) and passes the raw object to `GenericJsonRenderer` as `data` (additive, schemaVersion stays 1.0). `eventType` is set either way - same value the `Event` fact already carried.
## 2026-09-01 (2)
* **New**: `backend/ops-events-query-surface.md` - the ops-events read gains server-side `eventType` / `severity` / `minSeverity` filters (audit-log filter pattern: one parse helper, one clause appender used by both filter builders, values folded into the continuation fingerprint and echoed on nextLink). `minSeverity` expands to an OR-set because the severity names sort `Critical < Error < Info < Warning` lexicographically; the ladder is now shared with `OpsAlertDispatchService`. Fixed alongside: the paged cross-category fan-out skipped `Platform` (Azure Monitor alerts) because its category list had drifted from `OpsEventCategory` - the list is now `OpsEventCategory.All` with a reflection coverage test. MCP `get_ops_events` exposes the filters plus a client-resolved `days` shorthand.
## 2026-09-01 (1)
* **Update**: `agent/cmtrace-time-resolution.md` — the backend skew tripwire now judges only the session's most recent ingest era (batch stamps walked back from the newest, split at the first `ReceivedAt` gap > 2 h). Pre-provisioning partitions hold a Part 1 leg written weeks earlier by an older agent build: on 2026-09-01 three sessions of one tenant fired on 26 IME samples at −60 min left by agent 2.0.1409 on 2026-08-20 (pre per-line anchoring), against a device already self-updated to 2.0.1445. `SessionSkewScan` carries `ReceivedAt` per sample (distinct-batch counts derive from the era-filtered set; the cap keeps the newest samples), and the ops event reports `eraStartUtc` plus the excluded per-side counts.
## 2026-08-31 (2)
* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests.
## 2026-08-31 (1)
* **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `<summary>` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job.
## 2026-08-30 (15)
* **Update**: `backend/lifecycle-manifests-and-session-scope.md` — SessionTenantLookup is now claimed first-writer-wins at registration (create-only Add BEFORE any session write; foreign owner ⇒ 409 `session_owner_mismatch` + `SessionTenantConflict` ops event, nothing written; not fail-soft). Closes the poisoning path where any tenant's device could re-point a victim sessionId's mapping at its own tenant and hijack every no-tenantId Global Admin/Reader read and GA write (annotation lane). `ResolveSessionScopeAsync(requireGlobalAdmin: true)` corroborates the resolved tenant against its Sessions row (silent — no operator confirmation). Self-heal write is Add-only; `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the legacy window once. No tombstone on deletion (deliberate: unbounded growth for no gain).
## 2026-08-30 (14)
* **Update**: `agent/decision-engine.md` — new invariant: every payload-derived string stored into `DecisionState` is bounded to 256 chars by `FactStringBounds` before any dedupe comparison (RealmJoin package id/version/scope, failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`, RJ product version/channel). Backend `ReducerVerifier` now replays decode-fold-discard instead of materialising all decoded signals, and `ISignalRepository.QueryBySessionAsync` carries a cumulative `PayloadJson` budget (`SignalQueryLimits`, 32 M chars) reflected in the `truncated` flag of `sessions/{id}/signals` and `sessions/{id}/reducer-verification`.
## 2026-08-30 (13)
* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
## 2026-08-30 (12)
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
## 2026-08-30 (11)
* **New**: `agent/ime-process-watcher.md` — `ImeProcessWatcher` no longer attaches to the first `IntuneManagementExtension` name match: `ImeProcessIdentity` requires session 0 + image under the IME install root (`QueryFullProcessImageName`, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and re-arms discovery after every reported exit (previously one-shot — a fake in a user session could forge `ime_process_exited` and mute the real signal for the session; even an IME service restart was never watched again). Registered in `index.md`.
+1 -1
View File
@@ -123,4 +123,4 @@ Application Insights resource — the prefetch is cheap only while the runtime i
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
* `.github/workflows/deploy-web.yml` — hosting model: static export built in CI (Node 22 via setup-node, `engines` in package.json), uploaded with the pinned SWA CLI
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -158,6 +159,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.False(health.HasSkippedWork);
}
[Fact]
public async Task Starved_timeout_is_retried_once_and_the_recovered_match_is_not_skipped_work()
{
// Session b9f1d134: a CPU-starved Hyper-V guest tripped the 1 s wall-clock regex
// timeout on a line the pattern genuinely matches in 5.8 µs. A timeout whose attempt
// consumed almost no match cost is starvation — retried once, recovered, no warning.
using var h = new Harness();
var degraded = 0;
h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
h.Tracker.MatchCostReader = () => 0; // attempt cost 0 < gate
var attempts = 0;
h.Tracker.MatchInvoker = (id, regex, input) =>
{
if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
return regex.Match(input);
};
h.Append(Entry("marker 1") + "\n");
await h.Pass();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal(2, attempts);
Assert.Equal(1, health.PatternHits["T-MARK"]); // recovered
Assert.Equal(0, health.RegexTimeouts);
Assert.Equal(1, health.RegexTimeoutRetries);
Assert.False(health.HasSkippedWork);
Assert.Equal(0, degraded);
}
[Fact]
public async Task Timeout_that_burned_real_cpu_is_skipped_without_a_retry()
{
// The gate exists so a genuinely spinning pattern never gets a second helping of
// CPU: an attempt at or over the gate is a real timeout, counted and skipped. The
// wall-clock fallback lands here by construction (a timed-out attempt always
// measures >= the full timeout > gate).
using var h = new Harness();
var degraded = 0;
h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
h.Tracker.LineMatchBudget = long.MaxValue; // isolate the gate from the budget
long cost = 0;
h.Tracker.MatchCostReader = () => cost += h.Tracker.TimeoutRetryGate; // every delta >= gate
var attempts = 0;
h.Tracker.MatchInvoker = (id, regex, input) =>
{
if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
return regex.Match(input);
};
h.Append(Entry("marker 1 loose 1") + "\n");
await h.Pass();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal(1, attempts); // no retry
Assert.Equal(0, health.PatternHits["T-MARK"]);
Assert.Equal(1, health.PatternHits["T-LOOSE"]); // later patterns still ran
Assert.Equal(1, health.RegexTimeouts);
Assert.Equal(0, health.RegexTimeoutRetries);
Assert.True(health.HasSkippedWork);
Assert.Equal(1, degraded);
}
[Fact]
public async Task Retry_that_times_out_again_is_counted_once_and_not_retried_further()
{
using var h = new Harness();
h.Tracker.MatchCostReader = () => 0; // every attempt looks starved
var attempts = 0;
h.Tracker.MatchInvoker = (id, regex, input) =>
{
if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
return regex.Match(input);
};
h.Append(Entry("marker 1") + "\n");
await h.Pass();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal(2, attempts); // exactly one retry
Assert.Equal(1, health.RegexTimeouts);
Assert.Equal(1, health.RegexTimeoutRetries);
Assert.True(health.HasSkippedWork);
}
[Fact]
public async Task Budget_break_skips_the_remaining_patterns_and_names_the_first_skipped()
{
using var h = new Harness();
string? firstSkipped = null;
h.Tracker.OnTrackerDegraded = (_, __, pattern) => firstSkipped = pattern;
h.Tracker.LineMatchBudget = 500;
long cost = 0;
h.Tracker.MatchCostReader = () => cost += 1000; // every Match charges 1000 units
h.Append(Entry("marker 1 loose 1") + "\n");
await h.Pass();
var health = h.Tracker.GetHealthSnapshot();
Assert.Equal("T-NEVER", firstSkipped); // pattern order: T-MARK, T-NEVER, T-LOOSE
Assert.Equal(1, health.PatternHits["T-MARK"]); // matched before the budget was spent
Assert.Equal(0, health.PatternHits["T-LOOSE"]); // skipped by the break
Assert.Equal(1, health.BudgetBreaks);
Assert.True(health.HasSkippedWork);
}
[Fact]
public async Task Retry_counter_survives_a_restart()
{
using var h = new Harness();
h.Tracker.MatchCostReader = () => 0;
var attempts = 0;
h.Tracker.MatchInvoker = (id, regex, input) =>
{
if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
return regex.Match(input);
};
h.Append(Entry("marker 1") + "\n");
await h.Pass();
h.Tracker.SaveStateForTest();
h.Restart();
Assert.Equal(1, h.Tracker.GetHealthSnapshot().RegexTimeoutRetries);
}
[Fact]
public void Ime_agent_version_is_captured_from_the_pattern_action()
{
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
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.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry
{
/// <summary>
/// Pins the signal-pipeline block of <c>agent_metrics_snapshot</c> (2026-09-01 WG-seal
/// soak: sessions whose DecisionEngine never sealed were telemetrically
/// indistinguishable from a deliberate Weak classifier verdict, because the snapshots
/// carried no pipeline counters). The fields are monotonic totals plus a monotonic
/// queue peak — deliberately no momentary queue length — so two snapshots prove
/// whether the ingress worker ran in between.
/// </summary>
public sealed class AgentSelfMetricsCollectorSignalPipelineTests
{
private static AgentLogger NewLogger(string path)
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
private static (AgentSelfMetricsCollector Collector, FakeSignalIngressSink Sink) Build(
string tmpPath, Func<SignalPipelineHealth?>? probe)
{
var sink = new FakeSignalIngressSink();
var post = new InformationalEventPost(sink, SystemClock.Instance);
var collector = new AgentSelfMetricsCollector(
sessionId: "S1",
tenantId: "T1",
post: post,
networkMetrics: new NetworkMetrics(),
logger: NewLogger(tmpPath),
agentVersion: "2.0.test",
signalPipelineProbe: probe);
return (collector, sink);
}
private static IDictionary<string, object> CollectSnapshotData(
AgentSelfMetricsCollector collector, FakeSignalIngressSink sink)
{
collector.CollectSafe();
var snapshot = sink.Posted.Single(p =>
p.TypedPayload is IDictionary<string, object> d
&& d.ContainsKey("agent_version"));
return (IDictionary<string, object>)snapshot.TypedPayload!;
}
[Fact]
public void Snapshot_carries_pipeline_counters_stage_and_wg_verdict()
{
using var tmp = new TempDirectory();
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
{
LastAssignedSignalOrdinal = 41,
ProcessedSignalCount = 42,
PendingSignalCount = 3,
ReentrantPostCount = 7,
QueueLengthPeak = 12,
DecisionStage = "EspDeviceSetup",
LastAppliedSignalOrdinal = 40,
WhiteGloveSealingLevel = "Weak",
WhiteGloveSealingScore = 40,
});
var data = CollectSnapshotData(collector, sink);
Assert.Equal(41L, data["signal_last_ordinal"]);
Assert.Equal(42L, data["signal_processed_total"]);
Assert.Equal(3L, data["signal_pending_count"]);
Assert.Equal(7L, data["signal_reentrant_total"]);
Assert.Equal(12L, data["signal_queue_peak"]);
Assert.Equal("EspDeviceSetup", data["decision_stage"]);
Assert.Equal(40L, data["decision_last_applied_ordinal"]);
Assert.Equal("Weak", data["wg_sealing_level"]);
Assert.Equal(40, data["wg_sealing_score"]);
}
[Fact]
public void State_derived_fields_are_omitted_when_the_probe_has_no_state()
{
// Counters-only health (test fakes without a decision-state probe): the numeric
// block still lands, the state-derived keys must be ABSENT — not null-valued.
using var tmp = new TempDirectory();
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
{
ProcessedSignalCount = 5,
});
var data = CollectSnapshotData(collector, sink);
Assert.Equal(5L, data["signal_processed_total"]);
Assert.False(data.ContainsKey("decision_stage"));
Assert.False(data.ContainsKey("decision_last_applied_ordinal"));
Assert.False(data.ContainsKey("wg_sealing_level"));
Assert.False(data.ContainsKey("wg_sealing_score"));
}
[Fact]
public void Wg_fields_are_omitted_before_the_classifier_produced_a_verdict()
{
// Level stays null until the classifier ran — every non-WG session would
// otherwise carry a meaningless Unknown/0 pair on each snapshot.
using var tmp = new TempDirectory();
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
{
DecisionStage = "Monitoring",
LastAppliedSignalOrdinal = 9,
});
var data = CollectSnapshotData(collector, sink);
Assert.Equal("Monitoring", data["decision_stage"]);
Assert.False(data.ContainsKey("wg_sealing_level"));
Assert.False(data.ContainsKey("wg_sealing_score"));
}
[Fact]
public void Probe_failure_degrades_to_a_snapshot_without_the_block()
{
using var tmp = new TempDirectory();
var (collector, sink) = Build(tmp.Path,
() => throw new InvalidOperationException("probe exploded"));
var data = CollectSnapshotData(collector, sink);
Assert.True(data.ContainsKey("agent_version")); // snapshot itself survives
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
}
[Fact]
public void No_probe_means_no_pipeline_fields()
{
using var tmp = new TempDirectory();
var (collector, sink) = Build(tmp.Path, probe: null);
var data = CollectSnapshotData(collector, sink);
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
Assert.False(data.ContainsKey("decision_stage"));
}
}
}
@@ -64,7 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
ISignalIngressSink ingress,
IClock clock,
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null)
TimelineEventStream? timelineEvents = null,
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
{
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
CapturedIngress = ingress;
@@ -28,7 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
ISignalIngressSink ingress,
IClock clock,
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null)
TimelineEventStream? timelineEvents = null,
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
{
CapturedPatternIds = whiteGloveSealingPatternIds;
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
@@ -65,6 +65,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
return SpinWait.SpinUntil(condition, timeoutMs);
}
// ==================================== Pipeline-health counters (metrics snapshot)
// The agent_metrics_snapshot exports these as MONOTONIC totals so two snapshots can
// prove whether the worker ran in between (2026-09-01 WG-seal soak: a frozen engine
// and a deliberate Weak sealing verdict were otherwise indistinguishable).
[Fact]
public void ProcessedSignalCount_counts_fully_processed_signals()
{
using var rig = new Rig();
var ing = rig.Build();
Assert.Equal(0, ing.ProcessedSignalCount);
ing.Start();
for (int i = 0; i < 5; i++)
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"p-{i}"));
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 5),
$"expected 5 processed, saw {ing.ProcessedSignalCount}");
Assert.Equal(0, ing.PendingSignalCount);
ing.Stop();
Assert.Equal(5, ing.ProcessedSignalCount); // stable after drain
}
[Fact]
public void QueueLengthPeak_is_monotonic_and_survives_drain()
{
using var rig = new Rig();
var ing = rig.Build();
// Post BEFORE Start: the worker is not consuming yet, so the channel depth
// deterministically reaches the number of queued items.
for (int i = 0; i < 4; i++)
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"q-{i}"));
Assert.True(ing.QueueLengthPeak >= 4, $"peak {ing.QueueLengthPeak} < 4");
ing.Start();
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 4));
// Drained queue must NOT reset the peak — that is the whole point of exporting
// a peak instead of a momentary length (snapshot sampling cannot alias it away).
Assert.True(ing.QueueLengthPeak >= 4);
ing.Stop();
}
// ============================================================ Lifecycle
[Fact]
@@ -600,5 +600,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
}
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
private sealed class VersionSource
{
public RealmJoinVersionInfo Info = RealmJoinVersionInfo.Empty;
public string? DisplayVersion;
public RealmJoinVersionInfo Read() => Info;
public string? ReadDisplayVersion() => DisplayVersion;
}
private sealed class AutoUpdateHarness : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public VersionSource Versions { get; } = new VersionSource();
public RealmJoinWatcher Watcher { get; }
public RealmJoinWatcherAdapter Adapter { get; }
public AutoUpdateHarness()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var registry = new FakeRealmJoinRegistry();
Watcher = new RealmJoinWatcher(
logger,
enumeratePackageIds: registry.Enumerate,
tryReadPackage: registry.TryRead,
keyExists: registry.KeyExists,
readVersionInfo: Versions.Read,
readUninstallDisplayVersion: Versions.ReadDisplayVersion);
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
}
public void Dispose()
{
Adapter.Dispose();
Watcher.Dispose();
_tmp.Dispose();
}
}
[Fact]
public void AutoUpdate_fires_once_with_previous_and_new_version_when_binary_version_changes_after_detection()
{
// gktatooine session 946ccbd6: realmjoin_detected read 4.21.4 from the MSI-shipped
// binary; nine minutes later the tray AppUpdater swapped it for 4.21.18 and rewrote
// Uninstall\RealmJoin. The Detected read is the baseline; the re-read after the
// Uninstall-key change must surface exactly one auto-update event.
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
Assert.Equal("4.21.18", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
var info = Assert.Single(h.Ingress.Posted, p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", info.Payload!["previousVersion"]);
Assert.Equal("4.21.18", info.Payload["newVersion"]);
Assert.Equal("canary", info.Payload["releaseChannel"]);
Assert.Contains("4.21.4 -> 4.21.18", info.Payload[SignalPayloadKeys.Message]);
// Same version again (updater rewrote more values) — no duplicate.
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
}
[Fact]
public void AutoUpdate_stays_silent_when_version_is_unchanged_or_unreadable()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "release");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
// Unchanged (e.g. the tray creating the Uninstall entry on first start).
h.Watcher.TriggerCheckAutoUpdateFromTest();
// Unreadable (binary locked mid-swap, registry value not there yet) — baseline kept.
h.Versions.Info = RealmJoinVersionInfo.Empty;
h.Versions.DisplayVersion = null;
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
}
[Fact]
public void AutoUpdate_falls_back_to_Uninstall_DisplayVersion_and_keeps_baseline_channel()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
h.Versions.Info = RealmJoinVersionInfo.Empty; // RealmJoin.exe still held by the updater
h.Versions.DisplayVersion = "4.21.18"; // registry already rewritten
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
// DisplayVersion carries no channel tag — the Detected channel is the best evidence.
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
}
[Fact]
public void AutoUpdate_seeds_baseline_silently_when_Detected_could_not_read_the_binary()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = RealmJoinVersionInfo.Empty;
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
Assert.Null(h.Watcher.BaselineVersionForTest);
// First successful read has nothing to compare against — no phantom update.
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
// A later genuine change is still reported against the seeded baseline.
h.Versions.Info = new RealmJoinVersionInfo("4.21.19", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
}
}
}
@@ -331,6 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private sealed class PassStats
{
public int RegexTimeouts;
public int RegexTimeoutRetries;
public int BudgetBreaks;
public int OversizedLines;
public string FirstSkippedPatternId;
@@ -342,23 +343,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// remaining patterns are skipped for this line (counted, first skipped ID recorded) —
/// matches already handled on this line stand.
/// <para>
/// The budget is the SUM of the time spent inside <c>Regex.Match</c> on this line — the
/// cost the budget exists to bound (hostile input × unanchored custom patterns). It is
/// deliberately not the wall-clock from the first to the last pattern: that window also
/// contains the match handlers (signal ingress, match-log append, state writes) and
/// whatever the scheduler does to this thread in between. Session 946ccbd6 (2026-08-30)
/// recorded two budget breaks on a 1 MB `AppWorkload.log` with no line over 5 KB — the
/// Hyper-V guest had stalled the whole process for 5–10 s mid-line — and each break
/// silently skipped the remaining patterns of a genuine line.
/// The budget is the SUM of the COST spent inside <c>Regex.Match</c> on this line — the
/// resource the budget exists to bound (hostile input × unanchored custom patterns).
/// Cost is thread CPU cycles when <see cref="ThreadCycleTimeAvailable"/>, wall ticks
/// otherwise. Two production sessions forced the cycle move (both Hyper-V, 2026-08-30):
/// 946ccbd6 — a 5–10 s guest stall BETWEEN two patterns broke the then wall-clock-window
/// budget on a genuine line (fixed by charging only Match time); b9f1d134 — a stall
/// INSIDE Match calls (CPU pinned at 100 % during the DeviceSetup script burst) broke
/// the budget six times and tripped the 1 s regex timeout on a line PS-AGENT-OUTPUT
/// genuinely matches in 5.8 µs. Descheduled time accrues no cycles, so only the second
/// failure mode needed the cost change; the timeout itself is wall-clock inside .NET
/// and cannot be moved — hence the gated single retry below.
/// </para>
/// <para>
/// A <see cref="RegexMatchTimeoutException"/> whose attempt consumed less than
/// <see cref="TimeoutRetryGate"/> of cost was starvation, not matching — that attempt
/// is retried exactly once (counted as a retry, not a timeout; a recovered match is
/// handled normally and is NOT skipped work). An attempt at or over the gate burned
/// real CPU: counted as a timeout and skipped, never granted a second helping. In the
/// wall-clock fallback the gate is unreachable (a timed-out attempt always measures
/// ≥ the full timeout), so the fallback keeps the old skip-on-first-timeout behaviour.
/// </para>
/// </summary>
private void MatchLine(string filePath, string line, string messageToMatch, CmTraceLogEntry entry, PassStats stats)
{
long matchTicks = 0;
long matchCost = 0;
for (var i = 0; i < _activePatterns.Count; i++)
{
var pattern = _activePatterns[i];
if (matchTicks > LineMatchBudgetTicks)
if (matchCost > LineMatchBudget)
{
stats.BudgetBreaks++;
_budgetBreaks++;
@@ -366,22 +379,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
}
Match match;
var matchStarted = Stopwatch.GetTimestamp();
try
Match match = null;
var retried = false;
while (true)
{
match = pattern.Regex.Match(messageToMatch);
var costBefore = MatchCostReader();
try
{
match = MatchInvoker(pattern.PatternId, pattern.Regex, messageToMatch);
matchCost += MatchCostReader() - costBefore;
break;
}
catch (RegexMatchTimeoutException)
{
var attemptCost = MatchCostReader() - costBefore;
matchCost += attemptCost;
if (!retried && attemptCost < TimeoutRetryGate)
{
retried = true;
stats.RegexTimeoutRetries++;
_regexTimeoutRetries++;
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' consumed only {attemptCost} match-cost units (< gate {TimeoutRetryGate}) — starved thread, retrying once");
continue;
}
stats.RegexTimeouts++;
_regexTimeouts++;
if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}, attempt cost {attemptCost}) — skipped; further timeouts of this pattern are only counted");
break;
}
}
catch (RegexMatchTimeoutException)
{
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
stats.RegexTimeouts++;
_regexTimeouts++;
if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}) — skipped; further timeouts of this pattern are only counted");
continue;
}
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
if (match == null) continue;
if (match.Success)
{
@@ -241,12 +241,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// (IME writes an entry in one call) is far smaller.
internal const int HeldTailMaxBytes = 1024 * 1024;
// Aggregate matching budget per line across ALL active patterns. The 1 s per-regex
// timeout bounds one Match call; with ~80 active patterns and tenant-authored custom
// patterns (which are not anchored) a crafted line could still serialize ~80 s. After
// this budget the remaining patterns are skipped for that line and the pass summary
// says so. No genuine IME line comes anywhere near it.
private static readonly long LineMatchBudgetTicks = Stopwatch.Frequency * 2;
// Per-Match wall-clock timeout baked into every compiled Regex. .NET checks it against
// the wall clock internally, so it CANNOT be moved to CPU time — the starvation retry
// below exists precisely because of that.
internal static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);
// Whether per-thread CPU cycle accounting is available (probed once). When it is, the
// line budget and the timeout-retry gate are charged in cycles the matching thread
// actually executed; when not, both fall back to wall-clock Stopwatch ticks — exactly
// the pre-cycle behaviour, including "no retry ever" (see TimeoutRetryGate).
internal static readonly bool ThreadCycleTimeAvailable = Interop.ThreadNativeMethods.Probe();
// Converts "seconds of hostile CPU" into a cycle budget. Nominal frequency is unknowable
// in advance and precision is irrelevant: genuine lines cost microseconds (session
// b9f1d134: PS-AGENT-OUTPUT on its worst real line = 5.8 µs), the budget bounds seconds.
// A 1 GHz machine gets an effective 6 s budget, a 5 GHz machine 1.2 s — both fine.
private const long AssumedCyclesPerSecond = 3_000_000_000;
// Aggregate matching budget per line across ALL active patterns, charged in match COST
// (thread CPU cycles when available, wall ticks otherwise). The per-regex timeout bounds
// one Match call; with ~80 active patterns and tenant-authored custom patterns (which
// are not anchored) a crafted line could still serialize ~80 s of CPU. After this budget
// the remaining patterns are skipped for that line and the pass summary says so. No
// genuine IME line comes anywhere near it — but a starved thread no longer trips it,
// because descheduled time accrues no cycles (sessions 946ccbd6 / b9f1d134, 2026-08-30).
// Instance field as a test seam; initialised from the probed static default.
internal long LineMatchBudget =
ThreadCycleTimeAvailable ? AssumedCyclesPerSecond * 2 : Stopwatch.Frequency * 2;
// Gate for the ONE retry after a RegexMatchTimeoutException: retry only when the
// timed-out attempt consumed less than ~0.5 s of match cost — then the 1 s wall-clock
// timeout was starvation, and the retry costs what the match really costs (µs). A
// genuinely spinning pattern burns ~1 s of CPU into the attempt, exceeds the gate and
// is skipped without a second helping. In the wall-clock fallback the gate is
// unreachable by construction (a timed-out attempt always measures >= 1 s wall), so no
// retry ever fires there — starvation and spinning are indistinguishable without
// cycle accounting, and retrying a genuine spinner would double the hostile bound.
internal long TimeoutRetryGate =
ThreadCycleTimeAvailable ? AssumedCyclesPerSecond / 2 : Stopwatch.Frequency / 2;
// Monotonic per-thread match-cost counter (cycles or wall ticks; deltas taken around
// each Regex.Match on the poll thread only). Test seam.
internal Func<long> MatchCostReader = ThreadCycleTimeAvailable
? (Func<long>)Interop.ThreadNativeMethods.GetCurrentThreadCycles
: Stopwatch.GetTimestamp;
// Test seam for MatchLine: lets tests throw RegexMatchTimeoutException deterministically
// for a chosen pattern instead of constructing a genuinely catastrophic regex.
internal Func<string, Regex, string, Match> MatchInvoker =
(patternId, regex, input) => regex.Match(input);
// An unterminated tail at EOF (a physical line without '\n', or a multiline entry
// without its ']LOG]!>' close) is normally the writer mid-write. It is held back —
@@ -279,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private long _entriesMatched;
private long _oversizedLines;
private long _regexTimeouts;
private long _regexTimeoutRetries;
private long _budgetBreaks;
private long _heldTailCount;
private int _unanchoredPatterns;
@@ -306,6 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
EntriesMatched = _entriesMatched,
OversizedLines = _oversizedLines,
RegexTimeouts = _regexTimeouts,
RegexTimeoutRetries = _regexTimeoutRetries,
BudgetBreaks = _budgetBreaks,
HeldTails = _heldTailCount,
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
@@ -733,7 +778,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
// Singleline: make '.' match newlines too — required because multiline CMTrace
// entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101)
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1));
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, RegexMatchTimeout);
var compiled = new CompiledPattern
{
@@ -1000,6 +1045,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_entriesMatched = state.Health.EntriesMatched;
_oversizedLines = state.Health.OversizedLines;
_regexTimeouts = state.Health.RegexTimeouts;
_regexTimeoutRetries = state.Health.RegexTimeoutRetries;
_budgetBreaks = state.Health.BudgetBreaks;
_heldTailCount = state.Health.HeldTails;
HealthScriptResultParseFailures = state.Health.HealthScriptResultParseFailures;
@@ -1068,6 +1114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
EntriesMatched = _entriesMatched,
OversizedLines = _oversizedLines,
RegexTimeouts = _regexTimeouts,
RegexTimeoutRetries = _regexTimeoutRetries,
BudgetBreaks = _budgetBreaks,
HeldTails = _heldTailCount,
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
@@ -21,6 +21,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public long EntriesMatched { get; set; }
public long OversizedLines { get; set; }
public long RegexTimeouts { get; set; }
/// <summary>
/// Regex timeouts absorbed by the one gated retry: the timed-out attempt consumed
/// almost no CPU (a starved thread, not a spinning pattern) and the retry recovered
/// the match. Expected nonzero on CPU-saturated VMs; NOT skipped work — only a
/// timeout that survives (or is denied) the retry counts into <see cref="RegexTimeouts"/>.
/// </summary>
public long RegexTimeoutRetries { get; set; }
public long BudgetBreaks { get; set; }
public long HeldTails { get; set; }
public int HealthScriptResultParseFailures { get; set; }
@@ -159,6 +159,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public long EntriesMatched { get; set; }
public long OversizedLines { get; set; }
public long RegexTimeouts { get; set; }
public long RegexTimeoutRetries { get; set; }
public long BudgetBreaks { get; set; }
public long HeldTails { get; set; }
public int HealthScriptResultParseFailures { get; set; }
@@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>Defensive fallback for legacy 32-bit installs.</summary>
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
/// <summary>
/// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch
/// anchor when <see cref="UninstallRealmJoinPath"/> does not exist yet (the tray creates
/// the entry on its first start, after the service key is already present).
/// </summary>
public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
/// <summary>Sub-key name of the RJ entry under <see cref="UninstallRootPath"/>.</summary>
public const string UninstallRealmJoinKeyName = "RealmJoin";
/// <summary>
/// RJ's Add/Remove-Programs entry. The in-app updater (<c>RealmJoin.Core.AppUpdater</c>)
/// rewrites this key (<c>DisplayVersion</c>, <c>InstallDate</c>) in the same second it
/// swaps <c>RealmJoin.exe</c> — the only registry write that reflects an RJ self-update.
/// Neither <c>SOFTWARE\RealmJoin</c> nor <c>Services\realmjoin\Parameters</c> carry a version.
/// </summary>
public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName;
/// <summary>Bare version string under <see cref="UninstallRealmJoinPath"/>, e.g. <c>4.21.18</c> (no channel tag).</summary>
public const string DisplayVersionValueName = "DisplayVersion";
/// <summary>
/// Best-effort read of <c>DisplayVersion</c> under <see cref="UninstallRealmJoinPath"/>.
/// Fallback for the auto-update watcher when <c>RealmJoin.exe</c> is momentarily locked by
/// the updater; carries no release channel. <c>null</c> when absent or unreadable.
/// </summary>
public static string? TryReadUninstallDisplayVersion()
{
try
{
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = baseKey.OpenSubKey(UninstallRealmJoinPath, writable: false))
{
var raw = key?.GetValue(DisplayVersionValueName) as string;
return string.IsNullOrWhiteSpace(raw) ? null : raw!.Trim();
}
}
catch
{
return null;
}
}
/// <summary>
/// Release-channel name reported when the version string carries no SemVer prerelease
/// tag. Per the RJ developer, only beta/canary builds are tagged — untagged == stable.
@@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// RJ replaced its own binary after detection (tray <c>AppUpdater</c> "New version found …,
/// update required" on first start). <see cref="PreviousVersion"/> is the version reported
/// on <see cref="RealmJoinDetectedEventArgs"/> (or the last auto-update), <see cref="NewVersion"/>
/// the version now installed. Observability-only — the deployment lifecycle is unaffected.
/// </summary>
public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs
{
public string PreviousVersion { get; }
public string NewVersion { get; }
/// <summary>Channel of the new build; <c>null</c> when only the registry fallback was readable.</summary>
public string? ReleaseChannel { get; }
public RealmJoinAutoUpdateDetectedEventArgs(string previousVersion, string newVersion, string? releaseChannel)
{
PreviousVersion = previousVersion;
NewVersion = newVersion;
ReleaseChannel = releaseChannel;
}
}
public sealed class RealmJoinResolvedEventArgs : EventArgs
{
public int DeploymentPhase { get; }
@@ -104,13 +125,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal delegate IReadOnlyList<string> EnumeratePackageIdsDelegate(RegistryHive hive, string packagesPath);
internal delegate bool TryReadPackageDelegate(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot);
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
internal delegate string? ReadUninstallDisplayVersionDelegate();
/// <summary>
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
/// the updater rewrites several values and swaps <c>RealmJoin.exe</c> in the same window,
/// so the version re-read should land after the binary is back in place.
/// </summary>
internal const int AutoUpdateDebounceMilliseconds = 2000;
private readonly AgentLogger _logger;
private readonly EnumeratePackageIdsDelegate _enumeratePackageIds;
private readonly TryReadPackageDelegate _tryReadPackage;
private readonly KeyExistsDelegate _keyExists;
private readonly ReadVersionInfoDelegate _readVersionInfo;
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
private readonly object _stateLock = new object();
// Auto-update stage (armed once Detected fired, held until Stop).
private RegistryWatcher? _uninstallAppearanceWatcher;
private RegistryWatcher? _uninstallWatcher;
private Timer? _uninstallDebounce;
// Version baseline the next Uninstall-key change is compared against. Seeded from the
// Detected read; null when the binary was unreadable then — the first successful
// re-read seeds it silently instead of reporting a phantom update.
private string? _baselineVersion;
private string? _baselineChannel;
// Stage-1 parent-watchers (disposed on appearance).
private RegistryWatcher? _servicesAppearanceWatcher;
private RegistryWatcher? _machineSoftwareAppearanceWatcher;
@@ -149,6 +191,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public event EventHandler<RealmJoinPhaseChangedEventArgs>? RealmJoinPhaseChanged;
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageStarted;
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageCompleted;
public event EventHandler<RealmJoinAutoUpdateDetectedEventArgs>? RealmJoinAutoUpdateDetected;
public RealmJoinWatcher(AgentLogger logger)
: this(logger, RealmJoinInfo.EnumeratePackageIds, RealmJoinInfo.TryReadPackage, KeyExistsViaRegistry)
@@ -159,12 +202,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
AgentLogger logger,
EnumeratePackageIdsDelegate enumeratePackageIds,
TryReadPackageDelegate tryReadPackage,
KeyExistsDelegate keyExists)
KeyExistsDelegate keyExists,
ReadVersionInfoDelegate? readVersionInfo = null,
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
_tryReadPackage = tryReadPackage ?? throw new ArgumentNullException(nameof(tryReadPackage));
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
}
public void Start()
@@ -224,14 +271,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
DisposeAndNull(ref _servicesAppearanceWatcher);
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
DisposeAndNull(ref _uninstallAppearanceWatcher);
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
DisposeAndNull(ref _serviceRealmjoinWatcher);
DisposeAndNull(ref _machineRealmJoinWatcher);
DisposeAndNull(ref _userRealmJoinWatcher);
DisposeAndNull(ref _uninstallWatcher);
}
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
}
@@ -555,13 +605,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (fireDetected)
{
var initial = phase ?? 0;
// Read RJ version + release channel best-effort (fail-soft to null) ONLY when
// the Detected event actually fires — single read per session lifetime keeps
// any potential file-system cost to a single hit.
var versionInfo = RealmJoinInfo.TryReadRealmJoinVersionInfo();
// Read RJ version + release channel best-effort (fail-soft to null) when the
// Detected event fires. The result seeds the auto-update baseline; later reads
// only happen when the Uninstall key changes (see CheckAutoUpdate).
var versionInfo = _readVersionInfo();
lock (_stateLock)
{
_baselineVersion = versionInfo.ProductVersion;
_baselineChannel = versionInfo.ReleaseChannel;
}
_logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={versionInfo.ProductVersion ?? "<unknown>"}, releaseChannel={versionInfo.ReleaseChannel ?? "<unknown>"})");
try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, versionInfo.ProductVersion, versionInfo.ReleaseChannel)); }
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); }
EnsureUninstallStage();
}
if (firePhaseChange && prevPhase.HasValue && phase.HasValue)
@@ -593,6 +650,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
// ---- auto-update stage (Uninstall\RealmJoin) ---------------------------------------
/// <summary>
/// Arm the RJ self-update observer. The tray's <c>AppUpdater</c> rewrites
/// <see cref="RealmJoinInfo.UninstallRealmJoinPath"/> whenever it swaps the binary — on a
/// first start with a stale MSI payload this happens minutes after Detected, so the
/// version reported on <see cref="RealmJoinDetected"/> would otherwise be wrong for the
/// rest of the session. Same appearance-then-subtree pattern as the other stages; the
/// entry is created by the tray on first start, i.e. it may not exist at Detected time.
/// </summary>
private void EnsureUninstallStage()
{
if (_disposed) return;
if (_keyExists(RegistryHive.LocalMachine, RealmJoinInfo.UninstallRealmJoinPath))
{
AttachUninstallWatcher();
return;
}
ArmAppearanceParentWatcher(
role: "uninstall",
hive: RegistryHive.LocalMachine,
parentPath: RealmJoinInfo.UninstallRootPath,
targetSubKeyName: RealmJoinInfo.UninstallRealmJoinKeyName,
targetFullPath: RealmJoinInfo.UninstallRealmJoinPath,
slot: w => { lock (_stateLock) { _uninstallAppearanceWatcher = w; } },
clearSlot: () => { lock (_stateLock) { _uninstallAppearanceWatcher = null; } },
onAppeared: AttachUninstallWatcher);
}
private void AttachUninstallWatcher()
{
lock (_stateLock)
{
if (_disposed || _uninstallWatcher != null) return;
try
{
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
_uninstallWatcher = new RegistryWatcher(
RegistryHive.LocalMachine,
RealmJoinInfo.UninstallRealmJoinPath,
watchSubtree: false,
view: RegistryView.Registry64,
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
trace: msg => _logger.Trace($"RealmJoinWatcher(uninstall): {msg}"));
_uninstallWatcher.Changed += (s, e) => _uninstallDebounce?.Change(AutoUpdateDebounceMilliseconds, Timeout.Infinite);
_uninstallWatcher.Error += (s, ex) => _logger.Warning($"RealmJoinWatcher(uninstall): {ex.Message}");
_uninstallWatcher.Start();
_logger.Info("RealmJoinWatcher: attached auto-update watcher on Uninstall\\RealmJoin");
}
catch (Exception ex)
{
_logger.Warning($"RealmJoinWatcher: failed to attach Uninstall\\RealmJoin watcher: {ex.Message}");
}
}
// The key may already carry a newer version than the Detected read (update raced
// the appearance), so sweep once on attach — same as the other stages.
CheckAutoUpdate();
}
/// <summary>
/// Re-read the installed RJ version and fire <see cref="RealmJoinAutoUpdateDetected"/>
/// when it differs from the baseline. Primary source is the binary's file-version
/// resource (carries the channel); the Uninstall key's <c>DisplayVersion</c> is the
/// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept.
/// </summary>
private void CheckAutoUpdate()
{
try
{
var info = _readVersionInfo();
var newVersion = info.ProductVersion;
var newChannel = info.ReleaseChannel;
if (string.IsNullOrEmpty(newVersion))
{
newVersion = _readUninstallDisplayVersion();
newChannel = null;
if (string.IsNullOrEmpty(newVersion)) return;
}
string? previousVersion;
string? channelForEvent;
lock (_stateLock)
{
previousVersion = _baselineVersion;
if (previousVersion == null)
{
// Detected could not read the binary — seed silently, nothing to compare.
_baselineVersion = newVersion;
_baselineChannel = newChannel;
return;
}
if (string.Equals(previousVersion, newVersion, StringComparison.OrdinalIgnoreCase)) return;
channelForEvent = newChannel ?? _baselineChannel;
_baselineVersion = newVersion;
_baselineChannel = channelForEvent;
}
_logger.Info($"RealmJoinWatcher: RJ auto-update detected ({previousVersion} -> {newVersion}, releaseChannel={channelForEvent ?? "<unknown>"})");
try { RealmJoinAutoUpdateDetected?.Invoke(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion!, channelForEvent)); }
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinAutoUpdateDetected handler threw", ex); }
}
catch (Exception ex)
{
_logger.Warning($"RealmJoinWatcher: CheckAutoUpdate threw: {ex.Message}");
}
}
private void CheckMachinePackages()
{
CheckPackages(
@@ -766,6 +932,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted);
internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase);
internal void TriggerCheckAutoUpdateFromTest() => CheckAutoUpdate();
internal string? BaselineVersionForTest { get { lock (_stateLock) { return _baselineVersion; } } }
// Test seam — directly seed the dedup sets without touching the registry, so that a
// subsequent MaybeFirePackageEvents observation can verify suppression behavior.
@@ -0,0 +1,58 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Win32 P/Invoke for per-thread CPU accounting. Used by ImeLogTracker to charge the
/// per-line match budget with cycles the matching thread actually executed instead of
/// wall-clock time: on a CPU-saturated guest the wall clock keeps running while the
/// thread is descheduled, so a wall-based budget measures the scheduler, not the regex
/// (sessions 946ccbd6 / b9f1d134, 2026-08-30).
/// </summary>
internal static class ThreadNativeMethods
{
// Pseudo handle (-2): always refers to the calling thread, never needs closing.
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentThread();
// Cycles the thread has executed, accumulated by the kernel at context switches.
// Excludes time the thread spent descheduled by the GUEST scheduler; hypervisor-level
// vCPU preemption is invisible to the guest kernel and still gets charged — the
// measurement is a strict improvement over wall clock, not perfect steal accounting.
[DllImport("kernel32.dll")]
private static extern bool QueryThreadCycleTime(IntPtr threadHandle, out ulong cycleTime);
/// <summary>
/// Cycle count of the calling thread, or 0 if the call fails. A 0 makes the caller's
/// delta accounting charge nothing for the enclosed work — the safe direction (no
/// spurious skips); persistent unavailability is ruled out by <see cref="Probe"/>.
/// </summary>
public static long GetCurrentThreadCycles()
{
try
{
ulong cycles;
return QueryThreadCycleTime(GetCurrentThread(), out cycles) ? unchecked((long)cycles) : 0L;
}
catch (Exception)
{
return 0L;
}
}
/// <summary>One-shot availability check (missing export, denied call).</summary>
public static bool Probe()
{
try
{
ulong cycles;
return QueryThreadCycleTime(GetCurrentThread(), out cycles);
}
catch (Exception)
{
return false;
}
}
}
}
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
private readonly NetworkMetrics _networkMetrics;
private readonly ITelemetrySpool? _telemetrySpool;
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
private readonly Func<Orchestration.SignalPipelineHealth?>? _signalPipelineProbe;
// Previous sample for delta calculations
private TimeSpan _prevCpuTime;
@@ -53,13 +54,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
string agentVersion = "unknown",
int intervalSeconds = 60,
ITelemetrySpool? telemetrySpool = null,
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
Func<Orchestration.SignalPipelineHealth?>? signalPipelineProbe = null)
: base(sessionId, tenantId, post, logger, intervalSeconds)
{
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
_telemetrySpool = telemetrySpool;
_imeTrackerHealthProbe = imeTrackerHealthProbe;
_signalPipelineProbe = signalPipelineProbe;
}
protected override void OnBeforeStart()
@@ -82,11 +85,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
protected override void Collect()
{
// Full path writes 29 keys: agent_version + process metrics (5: cpu, ws, private,
// Full path writes 38 keys: agent_version + process metrics (5: cpu, ws, private,
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
// bytes_up/down, avg_latency, total_up/down/requests/latency) + IME tracker health
// (9). cap=29 → HashHelpers.GetPrime(29)=29 buckets → no resize on the last key.
var data = new Dictionary<string, object>(capacity: 29, StringComparer.Ordinal)
// (9) + signal pipeline (9: 5 counters, stage, applied ordinal, wg level+score).
// cap=38 → HashHelpers.GetPrime(38)=41 buckets → no resize on the last key.
var data = new Dictionary<string, object>(capacity: 38, StringComparer.Ordinal)
{
{ "agent_version", _agentVersion }
};
@@ -201,6 +205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["ime_entries_matched"] = health.EntriesMatched;
data["ime_oversized_lines"] = health.OversizedLines;
data["ime_regex_timeouts"] = health.RegexTimeouts;
data["ime_regex_timeout_retries"] = health.RegexTimeoutRetries;
data["ime_line_budget_breaks"] = health.BudgetBreaks;
data["ime_held_tails"] = health.HeldTails;
data["ime_unanchored_patterns"] = health.UnanchoredPatterns;
@@ -212,6 +217,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
}
// --- Signal/decision pipeline health ---
// Monotonic totals + a monotonic queue peak, deliberately NO momentary queue
// length: a snapshot samples the integral, so any two snapshots prove whether
// the ingress worker ran in between. Field motivation (2026-09-01 WG-seal soak):
// sessions whose engine never sealed were indistinguishable from a deliberate
// Weak classifier verdict without these fields. decision_stage answers the WG
// question directly; the wg_sealing_* pair separates "classifier said Weak"
// from "classifier never ran".
if (_signalPipelineProbe != null)
{
try
{
var pipeline = _signalPipelineProbe();
if (pipeline != null)
{
data["signal_last_ordinal"] = pipeline.LastAssignedSignalOrdinal;
data["signal_processed_total"] = pipeline.ProcessedSignalCount;
data["signal_pending_count"] = pipeline.PendingSignalCount;
data["signal_reentrant_total"] = pipeline.ReentrantPostCount;
data["signal_queue_peak"] = pipeline.QueueLengthPeak;
if (pipeline.DecisionStage != null)
data["decision_stage"] = pipeline.DecisionStage;
if (pipeline.LastAppliedSignalOrdinal.HasValue)
data["decision_last_applied_ordinal"] = pipeline.LastAppliedSignalOrdinal.Value;
if (pipeline.WhiteGloveSealingLevel != null)
{
data["wg_sealing_level"] = pipeline.WhiteGloveSealingLevel;
if (pipeline.WhiteGloveSealingScore.HasValue)
data["wg_sealing_score"] = pipeline.WhiteGloveSealingScore.Value;
}
}
}
catch (Exception ex)
{
Logger.Debug($"Signal pipeline health read failed: {ex.Message}");
}
}
if (data.Count > 0)
{
Post.Emit(new EnrollmentEvent
@@ -204,7 +204,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ISignalIngressSink ingress,
IClock clock,
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null)
TimelineEventStream? timelineEvents = null,
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
@@ -599,7 +600,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
startupGate: _startupEventGate, // M3 — disk_space_low latch survives restarts
// IME tracker health rides the metrics snapshot (queue depth + skip counters);
// read-only probe, same shape as the registry observer's trackerStateProbe.
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth()));
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth(),
// Stage + WG classifier verdict for the snapshot's signal-pipeline block
// (2026-09-01 WG-seal soak: without them a frozen engine and a deliberate
// Weak sealing verdict were telemetrically indistinguishable).
decisionStateProbe: decisionStateProbe));
}
// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the
@@ -667,7 +667,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var surfaces = _componentFactory.CreateCollectorHosts(
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
timelineEvents: _timelineEvents);
timelineEvents: _timelineEvents,
// Immutable-state reference read; null-safe against a processor that is
// torn down while a late metrics tick still fires.
decisionStateProbe: () => _processor?.CurrentState);
CollectorSurfaces = surfaces;
_collectorHosts = surfaces.Hosts;
foreach (var host in _collectorHosts)
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using SharedConstants = AutopilotMonitor.Shared.Constants;
@@ -56,6 +57,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly ITelemetrySpool? _telemetrySpool;
private readonly Persistence.StartupEventGate? _startupGate;
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
// Read-only probe into the reducer's current DecisionState (stage + WG classifier
// verdict) for the signal-pipeline block of agent_metrics_snapshot. DecisionState is
// immutable, so a cross-thread reference read yields a consistent (possibly slightly
// stale) snapshot — same access pattern the orchestrator's own Stop path uses.
private readonly Func<DecisionState?>? _decisionStateProbe;
// Codex Finding 4 — reference to the concrete SignalIngress (when available) so we
// can subscribe to its SignalPosted event. This lets us observe activity from
@@ -90,9 +96,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string agentVersion,
ITelemetrySpool? telemetrySpool = null,
Persistence.StartupEventGate? startupGate = null,
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
Func<DecisionState?>? decisionStateProbe = null)
{
_imeTrackerHealthProbe = imeTrackerHealthProbe;
_decisionStateProbe = decisionStateProbe;
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_sessionId = sessionId;
@@ -198,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
_selfMetricsCollector = new AgentSelfMetricsCollector(
_sessionId, _tenantId, _post, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds, _telemetrySpool,
imeTrackerHealthProbe: _imeTrackerHealthProbe);
imeTrackerHealthProbe: _imeTrackerHealthProbe,
signalPipelineProbe: BuildSignalPipelineProbe());
_selfMetricsCollector.Start();
}
_idleStopped = false;
@@ -215,6 +224,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_selfMetricsCollector = null;
}
/// <summary>
/// Builds the signal-pipeline probe for the metrics snapshot from whatever is
/// available: the concrete <see cref="SignalIngress"/> supplies the monotonic
/// worker counters, the decision-state probe supplies stage + WG classifier
/// verdict. Null only when NEITHER source exists (pure test fakes) — then the
/// snapshot simply omits the block, same degrade pattern as the IME health probe.
/// </summary>
private Func<SignalPipelineHealth?>? BuildSignalPipelineProbe()
{
var ingress = _observableIngress;
var stateProbe = _decisionStateProbe;
if (ingress == null && stateProbe == null) return null;
return () =>
{
var health = new SignalPipelineHealth();
if (ingress != null)
{
health.LastAssignedSignalOrdinal = ingress.LastAssignedSignalOrdinal;
health.ProcessedSignalCount = ingress.ProcessedSignalCount;
health.PendingSignalCount = ingress.PendingSignalCount;
health.ReentrantPostCount = ingress.ReentrantPostCount;
health.QueueLengthPeak = ingress.QueueLengthPeak;
}
var state = stateProbe?.Invoke();
if (state != null)
{
health.DecisionStage = state.Stage.ToString();
health.LastAppliedSignalOrdinal = state.LastAppliedSignalOrdinal;
// Only after the classifier produced at least one verdict — otherwise
// every non-WG session would carry a meaningless Unknown/0 pair.
var sealing = state.ClassifierOutcomes?.WhiteGloveSealing;
if (sealing?.LastClassifierVerdictId != null)
{
health.WhiteGloveSealingLevel = sealing.Level.ToString();
health.WhiteGloveSealingScore = sealing.Score;
}
}
return health;
};
}
private void OnRealEvent()
{
lock (_sync)
@@ -1,4 +1,5 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
@@ -47,6 +48,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
/// </para>
/// </summary>
/// <remarks>
/// <paramref name="decisionStateProbe"/> is a read-only probe into the reducer's current
/// immutable <see cref="AutopilotMonitor.DecisionCore.State.DecisionState"/> — it feeds the
/// signal-pipeline block of <c>agent_metrics_snapshot</c> (stage + WG classifier verdict).
/// Nullable; fakes pass null and the snapshot omits the state-derived fields.
/// </remarks>
CollectorSurfaces CreateCollectorHosts(
string sessionId,
string tenantId,
@@ -55,6 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ISignalIngressSink ingress,
IClock clock,
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null);
TimelineEventStream? timelineEvents = null,
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null);
}
}
@@ -94,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
private long _reentrantPostCount;
private long _queueLengthPeak;
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
private int _started; // 0 = not started, 1 = running
private int _stopRequested; // 0 = live, 1 = Stop called
@@ -156,6 +157,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
/// <summary>
/// Total signals the worker has fully processed (reduce + effects). Monotonic — the
/// agent_metrics_snapshot exports this so two snapshots prove whether the pipeline
/// worked in between (a frozen worker keeps posting-side counters growing while this
/// one stands still; a momentary queue-length sample could never show that).
/// </summary>
public long ProcessedSignalCount => Interlocked.Read(ref _processedCount);
/// <summary>
/// Highest channel queue length observed at any Post since Start. Monotonic
/// non-decreasing, so snapshot sampling cannot alias it away (same rationale as the
/// spool's PeakPendingItemCount). Worker-local follow-up items never count here.
/// </summary>
public long QueueLengthPeak => Interlocked.Read(ref _queueLengthPeak);
/// <summary>
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
@@ -317,6 +333,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (added)
{
UpdateQueueLengthPeak(_channel.Count);
RaiseSignalPosted(kind, payload);
return;
}
@@ -324,6 +341,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Slow path: bounded channel full → block, measure duration, notify observer throttled.
var blockStartedUtc = _clock.UtcNow;
int queueLenAtBlock = _channel.Count;
UpdateQueueLengthPeak(queueLenAtBlock);
try
{
_channel.Add(item);
@@ -345,6 +363,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
RaiseSignalPosted(kind, payload);
}
/// <summary>Lock-free monotonic max (net48 has no Interlocked max primitive).</summary>
private void UpdateQueueLengthPeak(int observedLength)
{
long observed = observedLength;
long current = Interlocked.Read(ref _queueLengthPeak);
while (observed > current)
{
var prior = Interlocked.CompareExchange(ref _queueLengthPeak, observed, current);
if (prior == current) return;
current = prior;
}
}
private void RaiseSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
// Snapshot the delegate once — standard pattern to tolerate concurrent subscribe /
@@ -0,0 +1,49 @@
#nullable enable annotations
using System;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Point-in-time read model of the signal/decision pipeline for the
/// <c>agent_metrics_snapshot</c> event. Motivated by the 2026-09-01 WG-seal soak
/// (memory: project_wg_seal_postfix_soak_residual): sessions whose DecisionEngine never
/// sealed were telemetrically indistinguishable from a deliberate Weak classifier verdict,
/// because the snapshots carried no signal-pipeline counters at all.
/// <para>
/// The counters are MONOTONIC totals, not momentary gauges — a snapshot samples the
/// integral, so any two snapshots prove whether the worker ran in between (a frozen
/// worker keeps <see cref="PendingSignalCount"/> growing while
/// <see cref="ProcessedSignalCount"/> / <see cref="LastAppliedSignalOrdinal"/> stand
/// still). Momentary queue length is deliberately exported only as a peak.
/// </para>
/// </summary>
public sealed class SignalPipelineHealth
{
/// <summary>Last SessionSignalOrdinal assigned by the ingress worker; −1 before the first signal.</summary>
public long LastAssignedSignalOrdinal { get; set; }
/// <summary>Total signals fully processed (reduce + effects). Monotonic.</summary>
public long ProcessedSignalCount { get; set; }
/// <summary>Signals accepted by Post but not yet fully processed (backlog gauge; pairs with the totals).</summary>
public long PendingSignalCount { get; set; }
/// <summary>Worker-thread re-entrant posts routed via the worker-local queue. Monotonic.</summary>
public long ReentrantPostCount { get; set; }
/// <summary>Highest channel queue length observed at any Post since Start. Monotonic non-decreasing.</summary>
public long QueueLengthPeak { get; set; }
/// <summary>Current DecisionState stage name (e.g. "EspDeviceSetup", "WhiteGloveSealed"). Null when no state is available.</summary>
public string? DecisionStage { get; set; }
/// <summary>The DecisionState's LastAppliedSignalOrdinal — the reducer-side twin of the ingress counters. Null when no state is available.</summary>
public long? LastAppliedSignalOrdinal { get; set; }
/// <summary>Last WhiteGlove-sealing classifier level ("Weak", "Confirmed", …). Null until the classifier produced a verdict.</summary>
public string? WhiteGloveSealingLevel { get; set; }
/// <summary>Score of the last WhiteGlove-sealing verdict. Null until the classifier produced a verdict.</summary>
public int? WhiteGloveSealingScore { get; set; }
}
}
@@ -322,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
["file"] = fileName ?? string.Empty,
["oversizedLines"] = health.OversizedLines,
["regexTimeouts"] = health.RegexTimeouts,
["regexTimeoutRetries"] = health.RegexTimeoutRetries,
["lineBudgetBreaks"] = health.BudgetBreaks,
["heldTails"] = health.HeldTails,
["linesRead"] = health.LinesRead,
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "RealmJoinWatcher";
private const string RegistryKeyHint = @"HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters";
private const string UninstallRegistryKeyHint = @"HKLM\" + RealmJoinInfo.UninstallRealmJoinPath;
private readonly RealmJoinWatcher _watcher;
private readonly ISignalIngressSink _ingress;
@@ -39,6 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged += OnPhaseChanged;
_watcher.RealmJoinPackageStarted += OnPackageStarted;
_watcher.RealmJoinPackageCompleted += OnPackageCompleted;
_watcher.RealmJoinAutoUpdateDetected += OnAutoUpdateDetected;
}
public void Dispose()
@@ -48,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged -= OnPhaseChanged;
_watcher.RealmJoinPackageStarted -= OnPackageStarted;
_watcher.RealmJoinPackageCompleted -= OnPackageCompleted;
_watcher.RealmJoinAutoUpdateDetected -= OnAutoUpdateDetected;
}
// Test seams
@@ -57,6 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr));
internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap));
internal void TriggerPackageCompletedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageCompleted(this, new RealmJoinPackageEventArgs(scope, snap));
internal void TriggerAutoUpdateDetectedFromTest(string previousVersion, string newVersion, string? releaseChannel) =>
OnAutoUpdateDetected(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion, releaseChannel));
private void OnDetected(object? sender, RealmJoinDetectedEventArgs e)
{
@@ -177,6 +182,56 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
private void OnAutoUpdateDetected(object? sender, RealmJoinAutoUpdateDetectedEventArgs e)
{
// Dual-emission: (1) the typed signal overrides the persisted version facts so
// final-status / replay carry the build that ran the deployment; (2) the timeline
// entry makes the self-update visible next to realmjoin_detected.
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = e.PreviousVersion,
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.NewVersion,
};
if (!string.IsNullOrEmpty(e.ReleaseChannel))
{
payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!;
}
_ingress.Post(
kind: DecisionSignalKind.RealmJoinAutoUpdateDetected,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: SourceLabel,
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "realmjoin-autoupdate-detected-v1",
summary: $"RealmJoin self-update observed ({e.PreviousVersion} -> {e.NewVersion}, releaseChannel={e.ReleaseChannel ?? "<unknown>"})",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["registryKey"] = UninstallRegistryKeyHint,
}),
payload: payload);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["previousVersion"] = e.PreviousVersion,
["newVersion"] = e.NewVersion,
["registryKey"] = UninstallRegistryKeyHint,
};
if (!string.IsNullOrEmpty(e.ReleaseChannel))
{
data["releaseChannel"] = e.ReleaseChannel!;
}
var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}";
_post.Emit(
eventType: SharedConstants.EventTypes.RealmJoinAutoUpdateDetected,
source: SourceLabel,
message: $"RealmJoin auto-updated {e.PreviousVersion} -> {e.NewVersion}{channelTail}",
severity: EventSeverity.Info,
immediateUpload: true,
data: data,
occurredAtUtc: _clock.UtcNow);
}
private void OnPackageStarted(object? sender, RealmJoinPackageEventArgs e)
{
var displayName = TruncateDisplayName(e.DisplayName);
@@ -236,24 +291,21 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = e.PackageId ?? string.Empty,
// Same bound the reducer applies (FactStringBounds) — keeps the signal itself,
// and therefore every upload carrying it, bounded at the source.
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = FactStringBounds.Bound(e.PackageId) ?? string.Empty,
[DecisionEngine.RealmJoinPayloadKeys.DisplayName] = displayName,
[DecisionEngine.RealmJoinPayloadKeys.Scope] = e.Scope ?? RealmJoinPackageFact.ScopeMachine,
};
if (!string.IsNullOrEmpty(e.Version))
{
payload[DecisionEngine.RealmJoinPayloadKeys.Version] = e.Version!;
payload[DecisionEngine.RealmJoinPayloadKeys.Version] = FactStringBounds.Bound(e.Version)!;
}
return payload;
}
private static string TruncateDisplayName(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value!.Length <= RealmJoinPackageFact.MaxDisplayNameLength
? value
: value.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength);
}
private static string TruncateDisplayName(string? value) =>
FactStringBounds.Bound(value) ?? string.Empty;
private static Evidence BuildEvidence(string identifier, string summary) =>
new Evidence(
@@ -579,6 +579,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
["entriesMatched"] = health.EntriesMatched,
["oversizedLines"] = health.OversizedLines,
["regexTimeouts"] = health.RegexTimeouts,
["regexTimeoutRetries"] = health.RegexTimeoutRetries,
["lineBudgetBreaks"] = health.BudgetBreaks,
["heldTails"] = health.HeldTails,
["unanchoredPatterns"] = health.UnanchoredPatterns,
@@ -837,5 +837,73 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
}
[Fact]
public void RealmJoinAutoUpdateDetected_overrides_version_facts_without_touching_gate_or_deadline()
{
var engine = new DecisionEngine();
var state = PrimeClassicAwaitingDesktop(engine);
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary",
}));
var deadlineBefore = Assert.Single(detected.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
// Channel omitted (registry DisplayVersion fallback) — the Detected channel must survive.
var updated = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.18",
}));
Assert.Null(updated.Transition.DeadEndReason);
Assert.Equal("4.21.18", updated.NewState.RealmJoinFacts.ProductVersion!.Value);
Assert.Equal("canary", updated.NewState.RealmJoinFacts.ReleaseChannel!.Value);
Assert.Equal(detected.NewState.Stage, updated.NewState.Stage);
Assert.False(DecisionEngine.RealmJoinGateOpen(updated.NewState));
var deadlineAfter = Assert.Single(updated.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
Assert.Equal(deadlineBefore.DueAtUtc, deadlineAfter.DueAtUtc);
Assert.Null(updated.NewState.RealmJoinFacts.LastActivityUtc);
Assert.Empty(updated.Effects);
// Channel supplied — overrides too.
var again = engine.Reduce(updated.NewState, MakeSignal(7, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(15),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.18",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.22.0",
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "release",
}));
Assert.Equal("4.22.0", again.NewState.RealmJoinFacts.ProductVersion!.Value);
Assert.Equal("release", again.NewState.RealmJoinFacts.ReleaseChannel!.Value);
}
[Fact]
public void RealmJoinAutoUpdateDetected_without_version_dead_ends_and_keeps_facts()
{
var engine = new DecisionEngine();
var state = PrimeClassicAwaitingDesktop(engine);
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
}));
var step = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
}));
Assert.Equal("realmjoin_autoupdate_missing_version", step.Transition.DeadEndReason);
Assert.Equal("4.21.4", step.NewState.RealmJoinFacts.ProductVersion!.Value);
}
}
}
@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Every payload-derived string that lands in <see cref="DecisionState"/> is bounded by
/// <see cref="FactStringBounds"/> at the reducer boundary. The server-side semantic replay
/// (<c>ReducerVerifier</c>) folds device-uploaded payloads through the same engine, so an
/// unbounded string there is a per-request memory/CPU amplifier. These tests pin that the
/// bound is applied BEFORE any dedupe comparison (so a forged multi-hundred-KB key neither
/// costs a full-length compare nor lands in state) and that real-sized values pass through
/// unchanged.
/// </summary>
public sealed class FactStringBoundsTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
private static string Long(char fill, int length, char last)
=> new string(fill, length - 1) + last;
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
payload: payload);
}
// ---------------------------------------------------------------- helper
[Theory]
[InlineData(null, null)]
[InlineData("", "")]
[InlineData("generic-vlc", "generic-vlc")]
public void Bound_passes_null_and_short_values_through(string? input, string? expected)
{
Assert.Equal(expected, FactStringBounds.Bound(input));
}
[Fact]
public void Bound_truncates_to_MaxLength()
{
var bounded = FactStringBounds.Bound(new string('x', FactStringBounds.MaxLength + 1))!;
Assert.Equal(FactStringBounds.MaxLength, bounded.Length);
Assert.Equal(FactStringBounds.MaxLength, RealmJoinPackageFact.MaxDisplayNameLength);
}
// ---------------------------------------------------------------- RealmJoinFacts
[Fact]
public void RealmJoin_package_keys_are_bounded_before_dedupe_so_forged_near_duplicates_collapse()
{
// Two ~300 KB ids that differ only in the final character — the exploit shape.
// After bounding both become the same 256-char key: one row, second start ignored.
var idA = Long('a', 300_000, '1');
var idB = Long('a', 300_000, '2');
var facts = RealmJoinFacts.Empty
.WithPackageStarted(idA, "A", Long('v', 5000, '1'), RealmJoinPackageFact.ScopeMachine, T0)
.WithPackageStarted(idB, "B", null, RealmJoinPackageFact.ScopeMachine, T0.AddSeconds(1));
var row = Assert.Single(facts.Packages);
Assert.Equal(FactStringBounds.MaxLength, row.PackageId.Length);
Assert.Equal(FactStringBounds.MaxLength, row.Version!.Length);
Assert.Equal("A", row.DisplayName);
}
[Fact]
public void RealmJoin_package_completed_matches_started_row_through_the_bound()
{
var id = Long('b', 100_000, 'x');
var facts = RealmJoinFacts.Empty
.WithPackageStarted(id, "B", "1.0", RealmJoinPackageFact.ScopeUser, T0)
.WithPackageCompleted(id, "B", "1.0", RealmJoinPackageFact.ScopeUser, T0.AddMinutes(1), success: true, lastExitCode: 0);
var row = Assert.Single(facts.Packages);
Assert.True(row.Success);
Assert.NotNull(row.CompletedUtc);
Assert.Equal(RealmJoinPackageFact.ScopeUser, row.Scope);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("MACHINE")]
[InlineData("forged-scope-value")]
public void RealmJoin_scope_is_normalised_to_the_two_known_constants(string? scope)
{
var facts = RealmJoinFacts.Empty.WithPackageStarted("generic-vlc", "VLC", null, scope!, T0);
Assert.Equal(RealmJoinPackageFact.ScopeMachine, Assert.Single(facts.Packages).Scope);
Assert.Equal(RealmJoinPackageFact.ScopeUser, RealmJoinPackageFact.NormalizeScope("user"));
}
[Fact]
public void RealmJoin_real_sized_values_are_stored_verbatim()
{
var facts = RealmJoinFacts.Empty.WithPackageStarted("generic-vlc", "VLC media player", "3.0.21.0", RealmJoinPackageFact.ScopeMachine, T0);
var row = Assert.Single(facts.Packages);
Assert.Equal("generic-vlc", row.PackageId);
Assert.Equal("3.0.21.0", row.Version);
Assert.Equal("VLC media player", row.DisplayName);
}
[Fact]
public void RealmJoin_replay_of_forged_package_stream_stays_cheap()
{
// 300 distinct ~200 KB ids against a full 200-row list. Unbounded, this was
// ~300 × 200 × 200 KB byte compares (tens of seconds); bounded it is microseconds.
// Generous ceiling — a regression is minutes, not milliseconds.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("bounds-sess", "bounds-tenant", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var sw = Stopwatch.StartNew();
for (var i = 1; i <= 300; i++)
{
var id = new string('p', 200_000 - 8) + i.ToString("D8");
state = engine.Reduce(state, MakeSignal(i, DecisionSignalKind.RealmJoinPackageStarted, T0.AddSeconds(i),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = id,
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
})).NewState;
}
sw.Stop();
// All 300 ids share their first 256 chars → they collapse to a single bounded row.
var row = Assert.Single(state.RealmJoinFacts.Packages);
Assert.Equal(FactStringBounds.MaxLength, row.PackageId.Length);
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"replay took {sw.Elapsed}");
}
[Fact]
public void RealmJoin_version_override_is_bounded()
{
var facts = RealmJoinFacts.Empty.WithVersionOverride(Long('v', 10_000, 'x'), Long('c', 10_000, 'y'), 7);
Assert.Equal(FactStringBounds.MaxLength, facts.ProductVersion!.Value.Length);
Assert.Equal(FactStringBounds.MaxLength, facts.ReleaseChannel!.Value.Length);
}
// ---------------------------------------------------------------- AppInstallFacts
[Fact]
public void AppInstall_failed_app_ids_are_bounded_before_dedupe()
{
var facts = AppInstallFacts.Empty
.WithFailed(Long('a', 50_000, '1'))
.WithFailed(Long('a', 50_000, '2'))
.WithFailed("real-app-guid");
Assert.Equal(3, facts.FailedCount);
Assert.Equal(2, facts.FailedAppIds.Count);
Assert.Equal(FactStringBounds.MaxLength, facts.FailedAppIds[0].Length);
Assert.Equal("real-app-guid", facts.FailedAppIds[1]);
}
// ---------------------------------------------------------------- scalar facts
[Fact]
public void EspAdvisoryFailureCategory_is_bounded()
{
var builder = DecisionState.CreateInitial("s", "t", T0).ToBuilder()
.WithEspAdvisoryFailureRecorded(T0, 1, Long('c', 10_000, 'x'));
Assert.Equal(FactStringBounds.MaxLength, builder.EspAdvisoryFailureCategory!.Value.Length);
}
[Fact]
public void HelloOutcome_from_payload_is_bounded_by_the_engine()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("hello-sess", "t", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = Long('h', 100_000, 'x') })).NewState;
Assert.NotNull(state.HelloOutcome);
Assert.Equal(FactStringBounds.MaxLength, state.HelloOutcome!.Value.Length);
}
[Fact]
public void ImeMatchedPatternId_from_payload_is_bounded_by_the_engine()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("ime-sess", "t", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(15, DecisionSignalKind.EspExiting, T0.AddMinutes(16.5))).NewState;
state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = Long('i', 100_000, 'x') })).NewState;
Assert.NotNull(state.ImeMatchedPatternId);
Assert.Equal(FactStringBounds.MaxLength, state.ImeMatchedPatternId!.Value.Length);
}
}
}
@@ -0,0 +1,933 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Offboarding;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Functions/Admin folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class AdminWireParityTests
{
private const string SampleTenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
// ---- GetActiveUsers ------------------------------------------------------------------
[Fact]
public void GetActiveUsersResponse_matches_the_presence_listing_shape()
{
var lastSeen = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc);
var anonymousUsers = new[]
{
new
{
tenantId = SampleTenantId,
upn = "admin@contoso.com",
userRole = "GlobalAdmin",
lastSeen,
secondsAgo = 42
},
}.ToList();
var typedUsers = new List<ActiveUserItem>
{
new ActiveUserItem
{
TenantId = SampleTenantId,
Upn = "admin@contoso.com",
UserRole = "GlobalAdmin",
LastSeen = lastSeen,
SecondsAgo = 42
},
};
AssertParity(
new
{
success = true,
windowMinutes = 5,
activeCount = anonymousUsers.Count,
users = anonymousUsers
},
new GetActiveUsersResponse
{
Success = true,
WindowMinutes = 5,
ActiveCount = typedUsers.Count,
Users = typedUsers
});
}
// ---- GetAuditLogs / GetGlobalAuditLogs (non-paged + paged share one DTO) -------------
private static AuditLogEntry SampleAuditLog() => new AuditLogEntry
{
Id = "20260830120000000_0001",
TenantId = SampleTenantId,
Action = "CREATE",
EntityType = "DeviceBlock",
EntityId = "SN-0042",
PerformedBy = "admin@contoso.com",
Timestamp = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
Details = "{\"Action\":\"Block\"}",
};
[Fact]
public void AuditLogListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
{
var logs = new List<AuditLogEntry> { SampleAuditLog() };
AssertParity(
new { success = true, count = logs.Count, logs },
new AuditLogListResponse
{
Success = true,
Count = logs.Count,
Logs = logs,
// NextLink stays null → the key is absent, exactly like the old
// non-paged literal that never declared it.
});
}
[Fact]
public void AuditLogListResponse_matches_the_paged_shape_with_a_nextLink()
{
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry> { SampleAuditLog() };
string? nextLink = "/api/audit/logs?pageSize=50&continuation=abc";
AssertParity(
new
{
success = true,
count = items.Count,
logs = items,
nextLink,
},
new AuditLogListResponse
{
Success = true,
Count = items.Count,
Logs = items,
NextLink = nextLink,
});
}
[Fact]
public void AuditLogListResponse_omits_a_null_nextLink_on_the_paged_path()
{
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry>();
string? nextLink = null;
AssertParity(
new
{
success = true,
count = items.Count,
logs = items,
nextLink,
},
new AuditLogListResponse
{
Success = true,
Count = items.Count,
Logs = items,
NextLink = null,
});
}
// ---- GetOpsEvents (non-paged + paged share one DTO) ----------------------------------
private static OpsEventEntry SampleOpsEvent() => new OpsEventEntry
{
Id = "20260830113000000_0007",
Category = "Maintenance",
EventType = "maintenance_completed",
Severity = "Info",
TenantId = SampleTenantId,
UserId = "admin@contoso.com",
Message = "Maintenance run completed",
Details = "{\"durationMs\":1200}",
Timestamp = new DateTime(2026, 8, 30, 11, 30, 0, DateTimeKind.Utc),
};
[Fact]
public void OpsEventListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
{
var filtered = new List<OpsEventEntry> { SampleOpsEvent() };
AssertParity(
new { success = true, count = filtered.Count, events = filtered },
new OpsEventListResponse
{
Success = true,
Count = filtered.Count,
Events = filtered,
});
}
[Fact]
public void OpsEventListResponse_matches_the_paged_shape_with_a_nextLink()
{
var pageItems = new List<OpsEventEntry> { SampleOpsEvent() };
string? nextLink = "/api/global/ops-events?pageSize=100&continuation=tok";
AssertParity(
new
{
success = true,
count = pageItems.Count,
events = pageItems,
nextLink,
},
new OpsEventListResponse
{
Success = true,
Count = pageItems.Count,
Events = pageItems,
NextLink = nextLink,
});
}
[Fact]
public void OpsEventListResponse_omits_a_null_nextLink_on_the_paged_path()
{
var pageItems = new List<OpsEventEntry>();
string? nextLink = null;
AssertParity(
new
{
success = true,
count = pageItems.Count,
events = pageItems,
nextLink,
},
new OpsEventListResponse
{
Success = true,
Count = pageItems.Count,
Events = pageItems,
NextLink = null,
});
}
// ---- GetAutopilotDeviceValidationConsentUrl ------------------------------------------
[Fact]
public void AutopilotConsentUrlResponse_matches_the_consent_url_shape()
{
var consentUrl = "https://login.example/6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/adminconsent?client_id=x";
var funnel = true;
AssertParity(
new
{
consentUrl,
willAutoFlipHoming = funnel
},
new AutopilotConsentUrlResponse
{
ConsentUrl = consentUrl,
WillAutoFlipHoming = funnel
});
}
// ---- GetAutopilotDeviceValidationConsentStatus ---------------------------------------
[Fact]
public void AutopilotConsentStatusResponse_matches_the_consent_status_shape()
{
string? message = "Consent verified";
AssertParity(
new
{
isConsented = true,
message,
homingFlipped = false
},
new AutopilotConsentStatusResponse
{
IsConsented = true,
Message = message,
HomingFlipped = false
});
}
[Fact]
public void AutopilotConsentStatusResponse_omits_a_null_message()
{
string? message = null;
AssertParity(
new
{
isConsented = false,
message,
homingFlipped = false
},
new AutopilotConsentStatusResponse
{
IsConsented = false,
Message = null,
HomingFlipped = false
});
}
// ---- GetAutopilotDeviceValidationAccessCheck -----------------------------------------
[Fact]
public void AutopilotAccessCheckResponse_matches_the_access_check_shape()
{
AssertParity(
new
{
accessPresent = true,
isTransient = false,
requiredPermission = "DeviceManagementServiceConfig.Read.All",
homingFlipped = true,
},
new AutopilotAccessCheckResponse
{
AccessPresent = true,
IsTransient = false,
RequiredPermission = "DeviceManagementServiceConfig.Read.All",
HomingFlipped = true,
});
}
// ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ----------------
[Fact]
public void BackfillJobRunResponse_matches_the_backfill_shape()
{
var result = new BackfillResult
{
Table = "audit",
DryRun = true,
RowsExamined = 250,
WouldWrite = 12,
Written = 0,
SkippedAlreadySet = 230,
SkippedUndecodable = 8,
Errors = 0,
NextContinuation = "token123",
};
var userEmail = "admin@contoso.com";
var triggeredAt = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
result,
triggeredBy = userEmail,
triggeredAt,
},
new BackfillJobRunResponse
{
Success = true,
Result = result,
TriggeredBy = userEmail,
TriggeredAt = triggeredAt,
});
}
[Fact]
public void ReclassifyJobRunResponse_matches_the_reclassify_shape()
{
var result = new ReclassificationResult
{
Mode = "legacy_timeouts",
DryRun = false,
TenantsExamined = 3,
SessionsExamined = 120,
WouldChange = 0,
Changed = 7,
ToSucceeded = 5,
ToIncomplete = 2,
KeptFailed = 110,
Skipped = 3,
Errors = 0,
CapReached = false,
};
var userEmail = "admin@contoso.com";
var triggeredAt = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
result,
triggeredBy = userEmail,
triggeredAt,
},
new ReclassifyJobRunResponse
{
Success = true,
Result = result,
TriggeredBy = userEmail,
TriggeredAt = triggeredAt,
});
}
// ---- TriggerMaintenance --------------------------------------------------------------
[Fact]
public void TriggerMaintenanceResponse_matches_the_manual_trigger_shape()
{
var result = new MaintenanceResult
{
Success = true,
TriggeredBy = "admin@contoso.com",
TriggeredAt = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc),
DurationMs = 1234,
StalledSessionsChecked = true,
MetricsAggregated = true,
AggregatedDate = "2026-08-29",
DataCleanupExecuted = false,
PlatformStatsRecomputed = true,
DevicesBlockedForExcessiveData = 0,
ContactEmailsBackfilled = 2,
};
var userEmail = "admin@contoso.com";
var triggeredAt = new DateTime(2026, 8, 30, 13, 10, 5, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
message = "Maintenance tasks completed",
result = result,
triggeredBy = userEmail,
triggeredAt
},
new TriggerMaintenanceResponse
{
Success = true,
Message = "Maintenance tasks completed",
Result = result,
TriggeredBy = userEmail,
TriggeredAt = triggeredAt
});
}
// ---- CustomsArchive (list runs / list entries / get entry / delete run) --------------
// The run/entry item classes moved verbatim (same property names + declaration order)
// from CustomsArchiveQueryFunction.RunSummary/EntrySummary into the Shared models, so
// the same instances serve as the item payload on both sides of the envelope proof.
[Fact]
public void CustomsArchiveRunListResponse_matches_the_run_listing_shape()
{
var runs = new List<CustomsArchiveRunSummary>
{
new CustomsArchiveRunSummary
{
PartitionKey = $"{SampleTenantId}_20260829120000",
TenantId = SampleTenantId,
HistoryRowKey = "20260829120000",
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
GatherRulesCount = 4,
AnalyzeRulesCount = 2,
ImeLogPatternsCount = 1,
},
};
AssertParity(
new { success = true, count = runs.Count, runs },
new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs });
}
[Fact]
public void CustomsArchiveEntryListResponse_matches_the_entry_listing_shape()
{
var items = new List<CustomsArchiveEntrySummary>
{
new CustomsArchiveEntrySummary
{
PartitionKey = $"{SampleTenantId}_20260829120000",
RowKey = "GatherRules_R0FUSEVSLTE",
OriginalTable = "GatherRules",
OriginalRowKey = "GATHER-1",
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
EntityJsonPreview = "{\"RuleId\":\"GATHER-1\"}",
},
};
AssertParity(
new { success = true, count = items.Count, entries = items },
new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items });
}
[Fact]
public void CustomsArchiveEntryResponse_matches_the_full_entry_shape()
{
var entry = new TenantOffboardingCustomsArchiveEntry
{
PartitionKey = $"{SampleTenantId}_20260829120000",
RowKey = "AnalyzeRules_QU5BTFlaRS0x",
TenantId = SampleTenantId,
OriginalTable = "AnalyzeRules",
OriginalPartitionKey = SampleTenantId,
OriginalRowKey = "ANALYZE-1",
EntityJson = "{\"RuleId\":\"ANALYZE-1\",\"Name\":\"Sample\"}",
HistoryRowKey = "20260829120000",
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
};
AssertParity(
new { success = true, entry },
new CustomsArchiveEntryResponse { Success = true, Entry = entry });
}
[Fact]
public void CustomsArchiveDeleteEntry_success_body_matches_SuccessOnlyResponse()
{
AssertParity(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
[Fact]
public void CustomsArchiveDeleteRunResponse_matches_the_bulk_delete_shape()
{
var deleted = 7;
AssertParity(
new { success = true, deleted },
new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted });
}
// ---- DelegatedAdminManagement --------------------------------------------------------
private static DelegatedAdminEntry SampleDelegatedAdmin() => new DelegatedAdminEntry
{
Upn = "msp@fabrikam.com",
TenantId = SampleTenantId,
Role = "DelegatedReader",
IsEnabled = true,
Status = "Active",
Source = "OperatorGranted",
GrantedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
GrantedBy = "admin@contoso.com",
};
[Fact]
public void DelegatedAdminListResponse_matches_the_assignments_shape()
{
var assignments = new List<DelegatedAdminEntry> { SampleDelegatedAdmin() };
AssertParity(
new { assignments },
new DelegatedAdminListResponse { Assignments = assignments });
}
[Fact]
public void DelegatedAdminGrantResponse_matches_the_single_assignment_shape()
{
var entry = SampleDelegatedAdmin();
AssertParity(
new { assignment = entry },
new DelegatedAdminGrantResponse { Assignment = entry });
}
// ---- DeviceBlock / GetAllBlockedDevices ----------------------------------------------
[Fact]
public void BlockedDeviceListResponse_matches_the_blocked_listing_shape()
{
var blocked = new List<BlockedDeviceEntry>
{
new BlockedDeviceEntry
{
TenantId = SampleTenantId,
SerialNumber = "SN-0042",
BlockedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
UnblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc),
BlockedByEmail = "admin@contoso.com",
DurationHours = 12,
Reason = "Excessive data",
Action = "Block",
BlockedSessionIds = null,
},
};
AssertParity(
new { success = true, blocked },
new BlockedDeviceListResponse { Success = true, Blocked = blocked });
}
[Fact]
public void BlockDeviceResponse_matches_the_block_acknowledgement_shape()
{
var serialNumber = "SN-0042";
var durationHours = 12;
var isKill = false;
var action = "Block";
var unblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
message = isKill
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
: $"Device {serialNumber} blocked for {durationHours} hours.",
unblockAt,
action
},
new BlockDeviceResponse
{
Success = true,
Message = $"Device {serialNumber} blocked for {durationHours} hours.",
UnblockAt = unblockAt,
Action = action
});
}
[Fact]
public void UnblockDevice_success_body_matches_SuccessMessageResponse()
{
var serialNumber = "SN-0042";
AssertParity(
new { success = true, message = $"Device {serialNumber} unblocked." },
new SuccessMessageResponse { Success = true, Message = $"Device {serialNumber} unblocked." });
}
// ---- EmailTemplates ------------------------------------------------------------------
[Fact]
public void EmailTemplateResponse_matches_the_effective_template_shape_with_an_override()
{
string? updatedBy = "admin@contoso.com";
DateTime? updatedUtc = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
AssertParity(
new
{
kind = "welcome",
subject = "Welcome to Autopilot Monitor",
isOverridden = true,
html = "<html>override</html>",
builtInHtml = "<html>{{domainName}}</html>",
updatedBy,
updatedUtc,
placeholder = "{{domainName}}",
maxLength = 30_000,
},
new EmailTemplateResponse
{
Kind = "welcome",
Subject = "Welcome to Autopilot Monitor",
IsOverridden = true,
Html = "<html>override</html>",
BuiltInHtml = "<html>{{domainName}}</html>",
UpdatedBy = updatedBy,
UpdatedUtc = updatedUtc,
Placeholder = "{{domainName}}",
MaxLength = 30_000,
});
}
[Fact]
public void EmailTemplateResponse_omits_updatedBy_and_updatedUtc_without_an_override()
{
string? updatedBy = null;
DateTime? updatedUtc = null;
AssertParity(
new
{
kind = "farewell",
subject = "Farewell from Autopilot Monitor",
isOverridden = false,
html = "<html>{{domainName}}</html>",
builtInHtml = "<html>{{domainName}}</html>",
updatedBy,
updatedUtc,
placeholder = "{{domainName}}",
maxLength = 30_000,
},
new EmailTemplateResponse
{
Kind = "farewell",
Subject = "Farewell from Autopilot Monitor",
IsOverridden = false,
Html = "<html>{{domainName}}</html>",
BuiltInHtml = "<html>{{domainName}}</html>",
UpdatedBy = null,
UpdatedUtc = null,
Placeholder = "{{domainName}}",
MaxLength = 30_000,
});
}
[Fact]
public void EmailTemplateSaveResponse_matches_the_save_acknowledgement_shape()
{
var updatedUtc = new DateTime(2026, 8, 30, 10, 5, 0, DateTimeKind.Utc);
AssertParity(
new { kind = "welcome", isOverridden = true, updatedBy = "admin@contoso.com", updatedUtc },
new EmailTemplateSaveResponse { Kind = "welcome", IsOverridden = true, UpdatedBy = "admin@contoso.com", UpdatedUtc = updatedUtc });
}
[Fact]
public void EmailTemplateResetResponse_matches_the_reset_acknowledgement_shape()
{
AssertParity(
new { kind = "farewell", isOverridden = false },
new EmailTemplateResetResponse { Kind = "farewell", IsOverridden = false });
}
[Fact]
public void EmailTemplateTestSendResponse_matches_the_test_send_shape()
{
AssertParity(
new { sentTo = "it@contoso.com", domainName = "contoso.com", draft = true },
new EmailTemplateTestSendResponse { SentTo = "it@contoso.com", DomainName = "contoso.com", Draft = true });
}
// ---- IdentityBindingManagement -------------------------------------------------------
private static AdminIdentityBinding SampleBinding() => new AdminIdentityBinding
{
Upn = "msp@fabrikam.com",
TenantId = "0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
ObjectId = "b1b1b1b1-1111-4222-8333-444455556666",
BoundBy = "admin@contoso.com",
BoundAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
ObjectIdPinnedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
};
[Fact]
public void IdentityBindingListResponse_matches_the_bindings_shape()
{
var bindings = new List<AdminIdentityBinding> { SampleBinding() };
AssertParity(
new { bindings },
new IdentityBindingListResponse { Bindings = bindings });
}
[Fact]
public void IdentityBindingResponse_matches_the_single_binding_shape()
{
var binding = SampleBinding();
AssertParity(
new { binding },
new IdentityBindingResponse { Binding = binding });
}
// ---- ReseedFromGitHub ----------------------------------------------------------------
[Fact]
public void ReseedFromGitHubResponse_matches_the_reseed_report_shape()
{
AssertParity(
new
{
success = true,
message = "Reseed from GitHub complete",
gather = new { deleted = 10, written = 12, orphanStatesGcd = 1, sunsetSkipped = 0 },
analyze = new { deleted = 8, written = 8, orphanStatesGcd = 0, sunsetSkipped = 1 },
ime = new { deleted = 79, written = 79 },
cpeCommunityMappings = new { deleted = 5, written = 6 },
cpeSeedMappings = new { deleted = 100, written = 100 }
},
new ReseedFromGitHubResponse
{
Success = true,
Message = "Reseed from GitHub complete",
Gather = new ReseedRuleCountsNode { Deleted = 10, Written = 12, OrphanStatesGcd = 1, SunsetSkipped = 0 },
Analyze = new ReseedRuleCountsNode { Deleted = 8, Written = 8, OrphanStatesGcd = 0, SunsetSkipped = 1 },
Ime = new ReseedTableCountsNode { Deleted = 79, Written = 79 },
CpeCommunityMappings = new ReseedTableCountsNode { Deleted = 5, Written = 6 },
CpeSeedMappings = new ReseedTableCountsNode { Deleted = 100, Written = 100 }
});
}
// ---- SubmitOffboardingFeedback -------------------------------------------------------
[Fact]
public void SubmitOffboardingFeedback_success_body_matches_SuccessOnlyResponse()
{
AssertParity(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
// ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not
// the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
// These pins fix the NEW shape exactly (key names, order, table-key absence).
[Fact]
public void TenantAdminCreatedResponse_pins_the_created_member_shape()
{
var newAdmin = new TenantAdminRow
{
TenantId = SampleTenantId,
Upn = "operator@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
Role = "Operator",
};
Assert.Equal(
"{\"admin\":{"
+ $"\"tenantId\":\"{SampleTenantId}\","
+ "\"upn\":\"operator@contoso.com\","
+ "\"isEnabled\":true,"
+ "\"addedDate\":\"2026-08-30T14:00:00Z\","
+ "\"addedBy\":\"admin@contoso.com\","
+ "\"role\":\"Operator\","
+ "\"canManageBootstrapTokens\":false}}",
TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin }));
}
[Fact]
public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys()
{
// Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the
// ITableEntity keys of the storage entity must never reappear on the wire.
var row = new TenantAdminRow
{
TenantId = SampleTenantId,
Upn = "legacy@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
Role = null,
};
var json = TestWire.Serialize(row);
Assert.Equal(
$"{{\"tenantId\":\"{SampleTenantId}\","
+ "\"upn\":\"legacy@contoso.com\","
+ "\"isEnabled\":true,"
+ "\"addedDate\":\"2026-08-30T14:05:00Z\","
+ "\"addedBy\":\"admin@contoso.com\","
+ "\"canManageBootstrapTokens\":false}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("timestamp", json);
}
// ---- TenantGroupManagement -----------------------------------------------------------
[Fact]
public void TenantGroupListResponse_matches_the_groups_shape()
{
var groups = new List<TenantGroup>
{
new TenantGroup
{
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
Name = "EU Customers",
CreatedBy = "admin@contoso.com",
CreatedAt = new DateTime(2026, 8, 27, 8, 0, 0, DateTimeKind.Utc),
TenantIds = new List<string> { SampleTenantId },
AssigneeCount = 1,
Assignees = new List<TenantGroupAssignment>
{
new TenantGroupAssignment
{
Upn = "msp@fabrikam.com",
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
Role = "DelegatedReader",
IsEnabled = true,
AssignedBy = "admin@contoso.com",
AssignedAt = new DateTime(2026, 8, 27, 8, 5, 0, DateTimeKind.Utc),
},
},
},
};
AssertParity(
new { groups },
new TenantGroupListResponse { Groups = groups });
}
[Fact]
public void CreateTenantGroupResponse_matches_the_created_group_shape()
{
var groupId = "c2c2c2c2-2222-4333-8444-555566667777";
var name = "EU Customers";
AssertParity(
new { groupId, name },
new CreateTenantGroupResponse { GroupId = groupId, Name = name });
}
// ---- VersionBlock --------------------------------------------------------------------
[Fact]
public void BlockedVersionListResponse_matches_the_rules_shape()
{
var rules = new List<BlockedVersionEntry>
{
new BlockedVersionEntry
{
VersionPattern = "1.4.*",
Action = "Block",
CreatedByEmail = "admin@contoso.com",
CreatedAt = new DateTime(2026, 8, 30, 7, 0, 0, DateTimeKind.Utc),
Reason = "Known crash loop",
},
};
AssertParity(
new { success = true, rules },
new BlockedVersionListResponse { Success = true, Rules = rules });
}
[Fact]
public void BlockVersionResponse_matches_the_block_acknowledgement_shape()
{
var versionPattern = "1.4.*";
var normalizedAction = "Kill";
AssertParity(
new
{
success = true,
message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
versionPattern,
action = normalizedAction
},
new BlockVersionResponse
{
Success = true,
Message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
VersionPattern = versionPattern,
Action = normalizedAction
});
}
[Fact]
public void UnblockVersion_success_body_matches_SuccessMessageResponse()
{
var versionPattern = "1.4.*";
AssertParity(
new { success = true, message = $"Version pattern '{versionPattern}' unblocked." },
new SuccessMessageResponse { Success = true, Message = $"Version pattern '{versionPattern}' unblocked." });
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Annotations function folder (GetSessionAnnotations,
/// ListSessionAnnotations, ListTenantSessionAnnotations, UpsertSessionAnnotation).
/// The anonymous literals below are copied verbatim from the pre-migration call sites
/// (including the item shapes built by AnnotationWire.ToWire/ToWireWithScope) and act as
/// the independent fixture against the new typed DTOs.
/// </summary>
public class AnnotationsWireParityTests
{
private static readonly DateTime Created = new DateTime(2026, 8, 29, 9, 15, 42, DateTimeKind.Utc);
private static readonly DateTime Updated = new DateTime(2026, 8, 30, 11, 22, 33, DateTimeKind.Utc);
[Fact]
public void GetSessionAnnotations_success_shape_is_wire_identical()
{
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
annotations = new List<object>
{
new
{
lane = "operator",
verdict = (string?)"root_cause_confirmed",
note = (string?)"Driver package blocked ESP; matches APP-017.",
authorUpn = "ops@contoso.com",
authorDisplayName = "Contoso Ops",
createdByUpn = "ops@contoso.com",
createdAtUtc = Created,
updatedAtUtc = Updated,
ruleIds = new List<string> { "APP-017", "NET-001" },
},
},
writableLanes = new List<string> { "operator", "tenantadmin" },
},
new GetSessionAnnotationsResponse
{
Success = true,
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
Annotations = new List<SessionAnnotationItem>
{
new SessionAnnotationItem
{
Lane = "operator",
Verdict = "root_cause_confirmed",
Note = "Driver package blocked ESP; matches APP-017.",
AuthorUpn = "ops@contoso.com",
AuthorDisplayName = "Contoso Ops",
CreatedByUpn = "ops@contoso.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Updated,
RuleIds = new List<string> { "APP-017", "NET-001" },
},
},
WritableLanes = new List<string> { "operator", "tenantadmin" },
});
}
[Fact]
public void GetSessionAnnotations_null_verdict_and_note_vanish_identically()
{
// A lane row can be verdict-only or note-only; null slots must drop their keys
// on both sides (WhenWritingNull). Also covers the empty-annotations page.
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
annotations = new List<object>
{
new
{
lane = "tenantadmin",
verdict = (string?)null,
note = (string?)null,
authorUpn = "admin@fabrikam.com",
authorDisplayName = "Fabrikam Admin",
createdByUpn = "admin@fabrikam.com",
createdAtUtc = Created,
updatedAtUtc = Updated,
ruleIds = new List<string>(),
},
},
writableLanes = new List<string>(),
},
new GetSessionAnnotationsResponse
{
Success = true,
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
Annotations = new List<SessionAnnotationItem>
{
new SessionAnnotationItem
{
Lane = "tenantadmin",
Verdict = null,
Note = null,
AuthorUpn = "admin@fabrikam.com",
AuthorDisplayName = "Fabrikam Admin",
CreatedByUpn = "admin@fabrikam.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Updated,
RuleIds = new List<string>(),
},
},
WritableLanes = new List<string>(),
});
}
[Fact]
public void SessionAnnotationList_success_shape_is_wire_identical()
{
// Shared by ListSessionAnnotations (global) and ListTenantSessionAnnotations —
// both sites emit { success, count, annotations(scoped), nextLink }.
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
count = 1,
annotations = new List<object>
{
new
{
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
lane = "globaladmin",
verdict = (string?)"analysis_wrong",
note = (string?)"Rule fired on the wrong phase.",
authorUpn = "ga@contoso.com",
authorDisplayName = "Platform GA",
createdByUpn = "ga@contoso.com",
createdAtUtc = Created,
updatedAtUtc = Updated,
ruleIds = new List<string> { "ESP-004" },
},
},
nextLink = (string?)"/api/global/session-annotations?pageSize=50&continuation=abc123",
},
new SessionAnnotationListResponse
{
Success = true,
Count = 1,
Annotations = new List<SessionAnnotationScopedItem>
{
new SessionAnnotationScopedItem
{
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
Lane = "globaladmin",
Verdict = "analysis_wrong",
Note = "Rule fired on the wrong phase.",
AuthorUpn = "ga@contoso.com",
AuthorDisplayName = "Platform GA",
CreatedByUpn = "ga@contoso.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Updated,
RuleIds = new List<string> { "ESP-004" },
},
},
NextLink = "/api/global/session-annotations?pageSize=50&continuation=abc123",
});
}
[Fact]
public void SessionAnnotationList_null_nextLink_and_null_item_slots_vanish_identically()
{
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
count = 1,
annotations = new List<object>
{
new
{
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
lane = "operator",
verdict = (string?)null,
note = (string?)"Note-only annotation.",
authorUpn = "ops@fabrikam.com",
authorDisplayName = "Fabrikam Ops",
createdByUpn = "ops@fabrikam.com",
createdAtUtc = Created,
updatedAtUtc = Updated,
ruleIds = new List<string>(),
},
},
nextLink = (string?)null,
},
new SessionAnnotationListResponse
{
Success = true,
Count = 1,
Annotations = new List<SessionAnnotationScopedItem>
{
new SessionAnnotationScopedItem
{
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
Lane = "operator",
Verdict = null,
Note = "Note-only annotation.",
AuthorUpn = "ops@fabrikam.com",
AuthorDisplayName = "Fabrikam Ops",
CreatedByUpn = "ops@fabrikam.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Updated,
RuleIds = new List<string>(),
},
},
NextLink = null,
});
}
[Fact]
public void UpsertSessionAnnotation_deleted_shape_is_wire_identical()
{
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, deleted = true },
new UpsertSessionAnnotationDeletedResponse { Success = true, Deleted = true });
}
[Fact]
public void UpsertSessionAnnotation_success_shape_is_wire_identical()
{
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
annotation = (object)new
{
lane = "operator",
verdict = (string?)"different_problem",
note = (string?)"Actually a proxy issue, not the app.",
authorUpn = "ops@contoso.com",
authorDisplayName = "Contoso Ops",
createdByUpn = "admin@contoso.com",
createdAtUtc = Created,
updatedAtUtc = Updated,
ruleIds = new List<string> { "NET-001" },
},
},
new UpsertSessionAnnotationResponse
{
Success = true,
Annotation = new SessionAnnotationItem
{
Lane = "operator",
Verdict = "different_problem",
Note = "Actually a proxy issue, not the app.",
AuthorUpn = "ops@contoso.com",
AuthorDisplayName = "Contoso Ops",
CreatedByUpn = "admin@contoso.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Updated,
RuleIds = new List<string> { "NET-001" },
},
});
}
[Fact]
public void UpsertSessionAnnotation_verdict_only_null_note_vanishes_identically()
{
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
annotation = (object)new
{
lane = "tenantadmin",
verdict = (string?)"inconclusive",
note = (string?)null,
authorUpn = "admin@fabrikam.com",
authorDisplayName = "Fabrikam Admin",
createdByUpn = "admin@fabrikam.com",
createdAtUtc = Created,
updatedAtUtc = Created,
ruleIds = new List<string>(),
},
},
new UpsertSessionAnnotationResponse
{
Success = true,
Annotation = new SessionAnnotationItem
{
Lane = "tenantadmin",
Verdict = "inconclusive",
Note = null,
AuthorUpn = "admin@fabrikam.com",
AuthorDisplayName = "Fabrikam Admin",
CreatedByUpn = "admin@fabrikam.com",
CreatedAtUtc = Created,
UpdatedAtUtc = Created,
RuleIds = new List<string>(),
},
});
}
}
@@ -0,0 +1,51 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the anonymous-object → typed-DTO migration: for every converted
/// call site there is a case here serializing the OLD anonymous literal (copied from the diff)
/// and the NEW DTO with the same values, comparing the JSON strings ordinally — key names,
/// key ORDER and presence/absence (WhenWritingNull) all must match, because MCP hands raw
/// response JSON to an LLM. Each site gets a populated case and, where a slot can be null at
/// runtime, a null-slot case (the key must vanish identically on both sides).
/// </summary>
public class ApiResponseWireParityTests
{
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
/// <summary>
/// Serialize both shapes with the production wire options and compare ordinally.
/// The typed side serializes as its runtime type — serializing as IApiResponse would
/// emit no properties at all.
/// </summary>
internal static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
{
var expected = JsonSerializer.Serialize(anonymousLiteral, anonymousLiteral.GetType(), WireOptions);
var actual = JsonSerializer.Serialize(typed, typed.GetType(), WireOptions);
Assert.Equal(expected, actual);
}
[Fact]
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
{
AssertWireIdentical(
new { success = true, message = "Rule created" },
new SuccessMessageResponse { Success = true, Message = "Rule created" });
AssertWireIdentical(
new { success = false, message = "Failed to create rule" },
new SuccessMessageResponse { Success = false, Message = "Failed to create rule" });
}
[Fact]
public void SuccessOnlyResponse_matches_the_canonical_anonymous_shape()
{
AssertWireIdentical(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
}
@@ -79,13 +79,7 @@ public class AppHomingServiceTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_sut = new AppHomingService(
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true),
};
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", collision: true),
};
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var apps = root.GetProperty("apps").EnumerateArray().ToList();
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
};
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
};
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
rows, sessionRepo.Object, App, days: 30));
var version = root.GetProperty("versionBreakdown")[0];
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
.ReturnsAsync((SessionSummary?)null);
var alert = Alert();
// The Functions host serializes camelCase on the wire; mirror that here so the
// TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
// asserted property names match what the web client actually parses.
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// Empty-data early return still surfaces the block (episodes can outlive window data).
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
new List<AppVersionRegressionAlert> { alert }));
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
new List<AppVersionRegressionAlert> { alert }));
var regression = root.GetProperty("versionRegressions")[0];
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
A("InProgress"),
};
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
{
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal("stable", app.GetProperty("trend").GetString());
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
// Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
Assert.False(app.TryGetProperty("trendDelta", out _));
}
// ── /apps/{appName}/analytics ───────────────────────────────────────────
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
}
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessionRepo, "Contoso App", days: 30);
return JsonSerializer.SerializeToElement(result);
return TestWire.SerializeToElement(result);
}
[Fact]
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
var root = JsonSerializer.SerializeToElement(
var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
}
private static JsonElement Build(int? pageSize, int skip = 0) =>
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
}
[Fact]
public void PageSize_LastPage_HasNullNextLink()
public void PageSize_LastPage_OmitsNextLink()
{
var root = Build(pageSize: 2, skip: 4);
Assert.Equal(1, root.GetProperty("count").GetInt32());
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind);
// Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
Assert.False(root.TryGetProperty("nextLink", out _));
}
}
@@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminEntity());
.ReturnsAsync(new TenantAdminRow());
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
@@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminEntity());
.ReturnsAsync(new TenantAdminRow());
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
@@ -471,7 +471,8 @@ public class AuthFunctionTests
// -------------------------------------------------------------------------
/// <summary>
/// Converts anonymous object to dynamic for property access in tests.
/// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names
/// (camelCase), so the asserts read the same casing a client would.
/// </summary>
private static dynamic ToDynamic(object obj)
{
@@ -479,7 +480,7 @@ public class AuthFunctionTests
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
}
return dict;
}
@@ -6,31 +6,44 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the CMTrace time-skew regression tripwire (CmTraceSkewTripwire.Evaluate): fires only
/// when the IME-vs-other median delta divergence sits on the 15-minute offset grid (residual
/// &lt; 2 min) AND both sides have enough samples over enough distinct upload batches. The
/// tripwire's goal state is to never fire, so the must-stay-silent directions are the primary
/// cases: off-grid replays, sub-grid divergence, thin samples, batch-composition artifacts,
/// and common-mode spool latency must all pass without a hit.
/// &lt; 2 min) AND both sides have enough samples over enough distinct upload batches, judged
/// over the session's most recent ingest era. The tripwire's goal state is to never fire, so
/// the must-stay-silent directions are the primary cases: off-grid replays, sub-grid
/// divergence, thin samples, batch-composition artifacts, common-mode spool latency, and the
/// skewed-but-ancient leg of a resumed pre-provisioning session must all pass without a hit.
/// </summary>
public class CmTraceSkewTripwireTests
{
/// <summary>Reference ingest era used by every single-era scan below.</summary>
private static readonly DateTime EraBase = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
/// <summary>Scan with the given per-side medians, comfortably above all sample/batch minimums.</summary>
private static SessionSkewScan MakeScan(
double imeDeltaMinutes, double otherDeltaMinutes,
int imeSamples = 30, int otherSamples = 30,
int imeBatches = 5, int otherBatches = 5)
{
var scan = new SessionSkewScan
{
ImeDistinctBatchCount = imeBatches,
OtherDistinctBatchCount = otherBatches,
};
for (int i = 0; i < imeSamples; i++)
scan.ImeDeltaMinutes.Add(imeDeltaMinutes);
for (int i = 0; i < otherSamples; i++)
scan.OtherDeltaMinutes.Add(otherDeltaMinutes);
var scan = new SessionSkewScan();
AddSamples(scan, isIme: true, imeDeltaMinutes, imeSamples, imeBatches, EraBase);
AddSamples(scan, isIme: false, otherDeltaMinutes, otherSamples, otherBatches, EraBase);
return scan;
}
/// <summary>
/// Adds <paramref name="count"/> identical samples spread round-robin over
/// <paramref name="batches"/> ReceivedAt stamps one minute apart, starting at
/// <paramref name="eraStart"/> — one continuous ingest era.
/// </summary>
private static void AddSamples(
SessionSkewScan scan, bool isIme, double deltaMinutes, int count, int batches, DateTime eraStart)
{
for (int i = 0; i < count; i++)
scan.Add(isIme, deltaMinutes, eraStart.AddMinutes(i % Math.Max(batches, 1)));
}
private static List<double> ImeDeltas(SessionSkewScan scan)
=> scan.ImeSamples.Select(s => s.DeltaMinutes).ToList();
// ── Must fire ──────────────────────────────────────────────────────────
[Fact]
@@ -106,6 +119,7 @@ public class CmTraceSkewTripwireTests
{
Assert.Null(CmTraceSkewTripwire.Evaluate(null));
Assert.Null(CmTraceSkewTripwire.Evaluate(new SessionSkewScan()));
Assert.Null(CmTraceSkewTripwire.ResolveEraStartUtc(new SessionSkewScan()));
}
[Fact]
@@ -114,8 +128,8 @@ public class CmTraceSkewTripwireTests
// A handful of replayed old lines (huge off-grid deltas) inside an otherwise healthy
// IME stream must not drag the median onto the grid.
var scan = MakeScan(imeDeltaMinutes: 0.5, otherDeltaMinutes: 0.4);
scan.ImeDeltaMinutes.Add(-1327.0);
scan.ImeDeltaMinutes.Add(-738.0);
scan.Add(isIme: true, -1327.0, EraBase);
scan.Add(isIme: true, -738.0, EraBase);
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
}
@@ -126,21 +140,23 @@ public class CmTraceSkewTripwireTests
// re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages
// (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance.
// The other side was live. The per-sample grid-conformity guard must keep this silent.
// The burst arrives in ONE batch, inside the same era as the live traffic.
var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80);
var burstReceivedAt = EraBase.AddMinutes(80);
void AddRamp(double from, double to, int count)
{
for (int i = 0; i < count; i++)
scan.ImeDeltaMinutes.Add(from + i * (to - from) / count);
scan.Add(isIme: true, from + i * (to - from) / count, burstReceivedAt);
}
AddRamp(46.0, 88.0, 125);
AddRamp(88.0, 100.0, 250);
AddRamp(100.0, 733.0, 125);
var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2);
var medianIme = ImeDeltas(scan).OrderBy(v => v).ElementAt(scan.ImeSamples.Count / 2);
Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5);
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(ImeDeltas(scan), 0.1) < 0.5);
}
[Fact]
@@ -150,7 +166,7 @@ public class CmTraceSkewTripwireTests
// zone. Every line is individually on the grid, so the conformity guard must NOT mask it.
var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65);
for (int i = 0; i < 35; i++)
scan.ImeDeltaMinutes.Add(120.0);
scan.Add(isIme: true, 120.0, EraBase);
var result = CmTraceSkewTripwire.Evaluate(scan);
Assert.NotNull(result);
@@ -166,6 +182,105 @@ public class CmTraceSkewTripwireTests
Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List<double>(), 0.0));
}
// ── Ingest-era window ──────────────────────────────────────────────────
private static readonly DateTime PartOneLeg = new DateTime(2026, 8, 20, 8, 20, 0, DateTimeKind.Utc);
private static readonly DateTime PartTwoLeg = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
[Fact]
public void Silent_OnPreProvisioningHandoverWhereOnlyTheOldLegWasSkewed()
{
// Field 2026-09-01, three sessions of one tenant (e797117b / c06d639d / d7c8032b):
// Part 1 ran on 2026-08-20 under agent 2.0.1409 — before per-line anchoring — and left
// 26 IME samples at exactly −60 min; Part 2 ran 12 days later on 2.0.1445 and is clean.
// The stale leg outnumbers the fresh one 26:9 and is perfectly grid-conformant, so
// nothing but the era window can separate them (event rows carry no agent version).
var scan = new SessionSkewScan();
AddSamples(scan, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
AddSamples(scan, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
AddSamples(scan, isIme: true, -0.16, count: 9, batches: 6, eraStart: PartTwoLeg);
AddSamples(scan, isIme: false, -0.26, count: 95, batches: 40, eraStart: PartTwoLeg);
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
Assert.Equal(PartTwoLeg, CmTraceSkewTripwire.ResolveEraStartUtc(scan));
// …and the old leg really is what used to fire: judged on its own it still does.
var oldLegOnly = new SessionSkewScan();
AddSamples(oldLegOnly, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
AddSamples(oldLegOnly, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
var legacy = CmTraceSkewTripwire.Evaluate(oldLegOnly);
Assert.NotNull(legacy);
Assert.Equal(-4, legacy!.GridSteps);
}
[Fact]
public void Fires_WhenTheCurrentEraIsSkewed_AndReportsWhatTheOlderLegContributed()
{
// The window must not become a blanket mute for resumed sessions: a skew in the leg the
// running agent just produced still fires, and the report says how much history it left
// out so an operator can tell a fresh regression from an inherited one.
var scan = new SessionSkewScan();
AddSamples(scan, isIme: true, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
AddSamples(scan, isIme: false, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
AddSamples(scan, isIme: true, -60.0, count: 25, batches: 8, eraStart: PartTwoLeg);
AddSamples(scan, isIme: false, 0.0, count: 40, batches: 12, eraStart: PartTwoLeg);
var result = CmTraceSkewTripwire.Evaluate(scan);
Assert.NotNull(result);
Assert.Equal(-4, result!.GridSteps);
Assert.Equal(-60.0, result.DiffMinutes, 3);
Assert.Equal(25, result.ImeSampleCount);
Assert.Equal(40, result.OtherSampleCount);
Assert.Equal(8, result.ImeBatchCount);
Assert.Equal(12, result.OtherBatchCount);
Assert.Equal(PartTwoLeg, result.EraStartUtc);
Assert.Equal(30, result.ImeSamplesOutsideEra);
Assert.Equal(30, result.OtherSamplesOutsideEra);
}
[Fact]
public void EraWindow_SplitsOnGapsWiderThanTheThreshold_NotOnTotalSpan()
{
// A long leg that keeps uploading is ONE era even when it spans far more than the gap
// threshold end to end — the boundary is the gap between consecutive batches.
var start = PartTwoLeg;
var continuous = new SessionSkewScan();
for (int i = 0; i < 8; i++)
continuous.Add(isIme: false, 0.0, start.AddMinutes(30 * i)); // 3.5 h span, 30 min gaps
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(continuous));
// Exactly at the threshold is still one era (the split needs a STRICTLY wider gap).
var atThreshold = new SessionSkewScan();
atThreshold.Add(isIme: false, 0.0, start);
atThreshold.Add(isIme: false, 0.0, start.AddHours(CmTraceSkewTripwire.EraGapHours));
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(atThreshold));
// One silence past the threshold ends the era, and the boundary is found across BOTH
// sides — an IME-only batch after the gap must still open the new era.
var split = new SessionSkewScan();
split.Add(isIme: false, 0.0, start);
split.Add(isIme: true, 0.0, start.AddHours(3));
Assert.Equal(start.AddHours(3), CmTraceSkewTripwire.ResolveEraStartUtc(split));
}
[Fact]
public void SampleCap_KeepsTheNewestSamples()
{
// The cap is a memory backstop for pathological sessions. Since the era window judges
// the NEWEST leg, the buffer must drop the oldest entries — keeping the head instead
// would blind the detector on exactly the sessions big enough to reach the cap.
var scan = new SessionSkewScan();
int adds = SessionSkewScan.MaxSamplesPerSide + 5_000;
for (int i = 0; i < adds; i++)
scan.Add(isIme: true, i, EraBase.AddSeconds(i));
Assert.True(scan.ImeSamples.Count >= SessionSkewScan.MaxSamplesPerSide);
Assert.True(scan.ImeSamples.Count < SessionSkewScan.MaxSamplesPerSide * 2);
Assert.Equal((double)(adds - 1), scan.ImeSamples[scan.ImeSamples.Count - 1].DeltaMinutes);
Assert.True(scan.ImeSamples[0].DeltaMinutes > 0); // the oldest adds were trimmed away
}
// ── Bias suppression predicate ─────────────────────────────────────────
[Fact]
@@ -30,12 +30,7 @@ public class CollectLogsQuickConfigOpsEventTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
}
@@ -0,0 +1,650 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Config;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Config function folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class ConfigWireParityTests
{
// ---- GetAllTenantConfigurations (delegated one-shot page + GA paginated mode) --------
[Fact]
public void GetAllTenantConfigurationsResponse_matches_the_paginated_projection_shape()
{
// TenantConfigProjection.ProjectAll returns List<Dictionary<string, object?>>.
var tenants = new List<Dictionary<string, object?>>
{
new Dictionary<string, object?>
{
["tenantId"] = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
["domainName"] = "contoso.com",
["planTier"] = "pro",
},
};
string? nextLink = "/api/config/all?pageSize=1&continuation=tok";
AssertParity(
new
{
count = tenants.Count,
tenants,
nextLink,
},
new GetAllTenantConfigurationsResponse
{
Count = tenants.Count,
Tenants = tenants,
NextLink = nextLink,
});
}
[Fact]
public void GetAllTenantConfigurationsResponse_omits_the_null_nextLink_of_the_delegated_one_shot_page()
{
var projected = new List<Dictionary<string, object?>>
{
new Dictionary<string, object?>
{
["tenantId"] = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e",
["domainName"] = "fabrikam.com",
},
};
AssertParity(
new
{
count = projected.Count,
tenants = projected,
nextLink = (string?)null,
},
new GetAllTenantConfigurationsResponse
{
Count = projected.Count,
Tenants = projected,
NextLink = null,
});
}
// ---- UpdateTenantAppHoming -----------------------------------------------------------
[Fact]
public void UpdateTenantAppHomingResponse_matches_the_flip_result_shape()
{
var changed = true;
string? homedAppClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
string? lastAuthClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
DateTime? lastAuthClientIdSince = new DateTime(2026, 8, 29, 6, 30, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
changed,
homedApp = "primary",
homedAppClientId,
lastAuthClientId,
lastAuthClientIdSince,
// Old ProbePayload helper built this anonymous shape.
probe = new
{
attempted = true,
succeeded = true,
isTransient = false,
},
},
new UpdateTenantAppHomingResponse
{
Success = true,
Changed = changed,
HomedApp = "primary",
HomedAppClientId = homedAppClientId,
LastAuthClientId = lastAuthClientId,
LastAuthClientIdSince = lastAuthClientIdSince,
Probe = new AppHomingProbeWire
{
Attempted = true,
Succeeded = true,
IsTransient = false,
},
});
}
[Fact]
public void UpdateTenantAppHomingResponse_omits_null_homing_slots()
{
// Legacy-homed tenant that never authenticated: pin and last-auth fields are null.
string? homedAppClientId = null;
string? lastAuthClientId = null;
DateTime? lastAuthClientIdSince = null;
AssertParity(
new
{
success = true,
changed = false,
homedApp = "legacy",
homedAppClientId,
lastAuthClientId,
lastAuthClientIdSince,
probe = new
{
attempted = false,
succeeded = false,
isTransient = false,
},
},
new UpdateTenantAppHomingResponse
{
Success = true,
Changed = false,
HomedApp = "legacy",
HomedAppClientId = null,
LastAuthClientId = null,
LastAuthClientIdSince = null,
Probe = new AppHomingProbeWire
{
Attempted = false,
Succeeded = false,
IsTransient = false,
},
});
}
// ---- GetLatestVersions ---------------------------------------------------------------
[Fact]
public void GetLatestVersionsResponse_matches_the_versions_shape()
{
string? agentVersion = "2.14.3.0";
string? bootstrapVersion = "1.9";
string? sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
DateTimeOffset? fetchedAtUtc = new DateTimeOffset(2026, 8, 30, 14, 0, 0, TimeSpan.Zero);
AssertParity(
new
{
latestAgentVersion = agentVersion,
latestBootstrapScriptVersion = bootstrapVersion,
latestAgentSha256 = sha256,
fetchedAtUtc,
source = "cache"
},
new GetLatestVersionsResponse
{
LatestAgentVersion = agentVersion,
LatestBootstrapScriptVersion = bootstrapVersion,
LatestAgentSha256 = sha256,
FetchedAtUtc = fetchedAtUtc,
Source = "cache"
});
}
[Fact]
public void GetLatestVersionsResponse_omits_all_null_slots_when_the_version_blob_was_unavailable()
{
// versions == null at the site: every ?. projection is null, source falls back to "blob".
string? agentVersion = null;
string? bootstrapVersion = null;
string? sha256 = null;
DateTimeOffset? fetchedAtUtc = null;
AssertParity(
new
{
latestAgentVersion = agentVersion,
latestBootstrapScriptVersion = bootstrapVersion,
latestAgentSha256 = sha256,
fetchedAtUtc,
source = "blob"
},
new GetLatestVersionsResponse
{
LatestAgentVersion = null,
LatestBootstrapScriptVersion = null,
LatestAgentSha256 = null,
FetchedAtUtc = null,
Source = "blob"
});
}
// ---- GetTenantConfigFieldsSchema -----------------------------------------------------
[Fact]
public void GetTenantConfigFieldsSchemaResponse_matches_the_schema_shape()
{
IReadOnlyList<TenantConfigFieldSchema> schema = new List<TenantConfigFieldSchema>
{
new TenantConfigFieldSchema(
name: "contactEmail",
type: "string",
format: "email",
nullable: true,
writable: true,
reason: null,
gaOnly: false,
revertProtected: false),
new TenantConfigFieldSchema(
name: "homedAppClientId",
type: "string",
format: null,
nullable: true,
writable: false,
reason: "system-owned",
gaOnly: true,
revertProtected: true),
};
AssertParity(
new
{
count = schema.Count,
writableCount = schema.Count(f => f.Writable),
fields = schema,
},
new GetTenantConfigFieldsSchemaResponse
{
Count = schema.Count,
WritableCount = schema.Count(f => f.Writable),
Fields = schema,
});
}
// ---- ListTenantConfigBackups ---------------------------------------------------------
[Fact]
public void ListTenantConfigBackupsResponse_matches_the_backup_listing_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var backupId = "2519968812345678901-a1b2c3d4";
var backupTakenAt = new DateTime(2026, 8, 30, 9, 45, 0, DateTimeKind.Utc);
var changedBy = "admin@contoso.com";
var source = "mcp-patch";
string? reason = "enable webhook notifications";
Dictionary<string, string>? diff = new Dictionary<string, string>
{
["WebhookUrl"] = "(none) -> https://***MASKED***",
};
AssertParity(
new
{
tenantId,
backups = new List<object>
{
new
{
backupId,
backupTakenAt,
changedBy,
source,
reason,
diff,
},
},
},
new ListTenantConfigBackupsResponse
{
TenantId = tenantId,
Backups = new List<TenantConfigBackupItem>
{
new TenantConfigBackupItem
{
BackupId = backupId,
BackupTakenAt = backupTakenAt,
ChangedBy = changedBy,
Source = source,
Reason = reason,
Diff = diff,
},
},
});
}
[Fact]
public void ListTenantConfigBackupsResponse_omits_null_reason_and_diff()
{
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
var backupId = "2519968800000000000-e5f6a7b8";
var backupTakenAt = new DateTime(2026, 8, 29, 22, 10, 0, DateTimeKind.Utc);
string? reason = null;
Dictionary<string, string>? diff = null; // unparseable stored DiffJson → TryParseDiff returns null
AssertParity(
new
{
tenantId,
backups = new List<object>
{
new
{
backupId,
backupTakenAt,
changedBy = "system",
source = "portal-put",
reason,
diff,
},
},
},
new ListTenantConfigBackupsResponse
{
TenantId = tenantId,
Backups = new List<TenantConfigBackupItem>
{
new TenantConfigBackupItem
{
BackupId = backupId,
BackupTakenAt = backupTakenAt,
ChangedBy = "system",
Source = "portal-put",
Reason = null,
Diff = null,
},
},
});
}
// ---- PatchTenantConfigurationFields / RevertTenantConfiguration (shared WriteOutcome) -
[Fact]
public void TenantConfigPatchOutcomeResponse_matches_the_applied_patch_shape()
{
IReadOnlyCollection<string> appliedFields = new List<string> { "ContactEmail", "NotificationsEnabled" };
Dictionary<string, string>? diff = new Dictionary<string, string>
{
["ContactEmail"] = "(none) -> it@contoso.com",
["NotificationsEnabled"] = "False -> True",
};
string? backupId = "2519968812345678901-a1b2c3d4";
AssertParity(
new
{
success = true,
appliedFields,
diff,
backupId,
noOp = appliedFields.Count == 0,
},
new TenantConfigPatchOutcomeResponse
{
Success = true,
AppliedFields = appliedFields,
Diff = diff,
BackupId = backupId,
NoOp = appliedFields.Count == 0,
});
}
[Fact]
public void TenantConfigPatchOutcomeResponse_omits_null_diff_and_backupId_on_a_noop()
{
IReadOnlyCollection<string> appliedFields = new List<string>();
Dictionary<string, string>? diff = null;
string? backupId = null;
AssertParity(
new
{
success = true,
appliedFields,
diff,
backupId,
noOp = appliedFields.Count == 0,
},
new TenantConfigPatchOutcomeResponse
{
Success = true,
AppliedFields = appliedFields,
Diff = null,
BackupId = null,
NoOp = true,
});
}
// ---- SetTenantPlanTier ---------------------------------------------------------------
[Fact]
public void SetTenantPlanTierResponse_matches_the_plan_state_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
DateTime? trialExpiresUtc = new DateTime(2026, 9, 29, 12, 0, 0, DateTimeKind.Utc);
DateTime? retentionGraceEndsUtc = new DateTime(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc);
AssertParity(
new
{
tenantId,
planTier = "pro",
trialExpiresUtc,
trialConsumed = true,
effectiveEdition = "pro",
retentionGraceEndsUtc
},
new SetTenantPlanTierResponse
{
TenantId = tenantId,
PlanTier = "pro",
TrialExpiresUtc = trialExpiresUtc,
TrialConsumed = true,
EffectiveEdition = "pro",
RetentionGraceEndsUtc = retentionGraceEndsUtc
});
}
[Fact]
public void SetTenantPlanTierResponse_omits_null_trial_and_grace_slots()
{
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
DateTime? trialExpiresUtc = null;
DateTime? retentionGraceEndsUtc = null;
AssertParity(
new
{
tenantId,
planTier = "community",
trialExpiresUtc,
trialConsumed = false,
effectiveEdition = "community",
retentionGraceEndsUtc
},
new SetTenantPlanTierResponse
{
TenantId = tenantId,
PlanTier = "community",
TrialExpiresUtc = null,
TrialConsumed = false,
EffectiveEdition = "community",
RetentionGraceEndsUtc = null
});
}
// ---- StartTenantTrial ----------------------------------------------------------------
[Fact]
public void StartTenantTrialResponse_matches_the_trial_start_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
DateTime? trialStartedUtc = new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc);
DateTime? trialExpiresUtc = trialStartedUtc.Value.AddDays(30);
AssertParity(
new
{
tenantId,
trialStartedUtc,
trialExpiresUtc,
effectiveEdition = "pro"
},
new StartTenantTrialResponse
{
TenantId = tenantId,
TrialStartedUtc = trialStartedUtc,
TrialExpiresUtc = trialExpiresUtc,
EffectiveEdition = "pro"
});
}
[Fact]
public void StartTenantTrialResponse_omits_null_trial_slots()
{
// The site always sets both dates before responding — this pins that a null slot
// would vanish identically on both sides anyway (compile-time type is DateTime?).
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
DateTime? trialStartedUtc = null;
DateTime? trialExpiresUtc = null;
AssertParity(
new
{
tenantId,
trialStartedUtc,
trialExpiresUtc,
effectiveEdition = "pro"
},
new StartTenantTrialResponse
{
TenantId = tenantId,
TrialStartedUtc = null,
TrialExpiresUtc = null,
EffectiveEdition = "pro"
});
}
// ---- GetPlanTierDefinitions / SetPlanTierDefinitions ---------------------------------
[Fact]
public void PlanTierDefinitionsResponse_matches_the_tiers_shape()
{
var tiers = new List<PlanTierDefinition>
{
new PlanTierDefinition
{
Name = "community",
DailyRequestLimit = 100,
MonthlyRequestLimit = 3000,
Description = "Free tier",
},
new PlanTierDefinition
{
Name = "pro",
DailyRequestLimit = 1000,
MonthlyRequestLimit = 30000,
Description = "Paid tier",
},
};
AssertParity(
new { tiers },
new PlanTierDefinitionsResponse { Tiers = tiers });
}
// ---- TestWebhookNotification (dual-purpose: success carries the delivery verdict) ----
[Fact]
public void TestWebhookNotificationResponse_matches_the_delivery_verdict_shape()
{
int? statusCode = 200;
AssertParity(
new
{
success = true,
statusCode,
message = "Test notification sent successfully"
},
new TestWebhookNotificationResponse
{
Success = true,
StatusCode = statusCode,
Message = "Test notification sent successfully"
});
}
[Fact]
public void TestWebhookNotificationResponse_omits_a_null_statusCode_when_the_send_got_no_response()
{
int? statusCode = null;
AssertParity(
new
{
success = false,
statusCode,
message = "The webhook endpoint could not be reached."
},
new TestWebhookNotificationResponse
{
Success = false,
StatusCode = null,
Message = "The webhook endpoint could not be reached."
});
}
// ---- UpdateAdminConfiguration --------------------------------------------------------
[Fact]
public void UpdateAdminConfigurationResponse_matches_the_ack_plus_config_shape()
{
var config = new AdminConfiguration
{
PartitionKey = "GlobalConfig",
RowKey = "config",
UpdatedBy = "admin@contoso.com",
};
AssertParity(
new
{
success = true,
message = "Admin configuration updated successfully",
config = config
},
new UpdateAdminConfigurationResponse
{
Success = true,
Message = "Admin configuration updated successfully",
Config = config
});
}
// ---- UpdateTenantConfiguration -------------------------------------------------------
[Fact]
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
{
var config = new TenantConfiguration
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
};
AssertParity(
new
{
success = true,
message = "Configuration updated successfully",
config = config
},
new UpdateTenantConfigurationResponse
{
Success = true,
Message = "Configuration updated successfully",
Config = config
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
private static void AssertProperty(object body, string propertyName, object? expected)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
Assert.Equal(expected, prop!.GetValue(body));
}
private static T GetProperty<T>(object body, string propertyName)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
var value = prop!.GetValue(body);
return (T)value!;
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
},
};
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
Assert.Equal(3, totals.CompletedJourneys);
Assert.Equal(2, totals.FirstTimeRight);
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
[Fact]
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
{
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
{
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
});
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
[InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
}
[Fact]
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
// days=1 summed yesterday AND today.
var today = new DateTime(2026, 7, 27);
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
}
private static DeviceHistory RepeaterHistory(
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
};
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(2, selected.Count);
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
}
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(10, selected.Count);
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
@@ -2,6 +2,7 @@ using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -43,10 +44,12 @@ public class GatherRulesFunctionAuthorEndpointTests
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
{
// WriteAsJsonAsync resolves the serializer from InstanceServices.
// WriteAsJsonAsync resolves the serializer from InstanceServices — use the production
// wire settings so assertions run against the shape the deployed worker serializes.
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
services.Configure<WorkerOptions>(o => o.Serializer =
new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>();
@@ -60,8 +60,8 @@ public class GetDeviceNotRegisteredFunctionTests
Assert.Single(aggregated);
Assert.Equal(3, totalRawReports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(3, (int)item.AttemptCount);
}
[Fact]
@@ -91,10 +91,10 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal("Lenovo", (string)item.manufacturer);
Assert.Equal("ThinkPad T14", (string)item.model);
Assert.Equal("SN1", (string)item.serialNumber);
var item = aggregated[0];
Assert.Equal("Lenovo", (string)item.Manufacturer);
Assert.Equal("ThinkPad T14", (string)item.Model);
Assert.Equal("SN1", (string)item.SerialNumber);
}
// =========================================================================
@@ -117,9 +117,9 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(t1, (DateTime)item.firstSeen);
Assert.Equal(t3, (DateTime)item.lastSeen);
var item = aggregated[0];
Assert.Equal(t1, (DateTime)item.FirstSeen);
Assert.Equal(t3, (DateTime)item.LastSeen);
}
[Fact]
@@ -135,12 +135,12 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
var first = ToDynamic(aggregated[0]);
var second = ToDynamic(aggregated[1]);
var third = ToDynamic(aggregated[2]);
var first = aggregated[0];
var second = aggregated[1];
var third = aggregated[2];
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
}
// =========================================================================
@@ -160,8 +160,8 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(3, (int)item.AttemptCount);
}
// =========================================================================
@@ -182,8 +182,8 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.True((bool)item.isCloudPc);
var item = aggregated[0];
Assert.True((bool)item.IsCloudPc);
}
[Fact]
@@ -196,8 +196,8 @@ public class GetDeviceNotRegisteredFunctionTests
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.False((bool)item.isCloudPc);
var item = aggregated[0];
Assert.False((bool)item.IsCloudPc);
}
// =========================================================================
@@ -219,14 +219,4 @@ public class GetDeviceNotRegisteredFunctionTests
IsCloudPc = isCloudPc,
};
private static dynamic ToDynamic(object obj)
{
var type = obj.GetType();
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -57,8 +57,8 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(3, (int)item.AttemptCount);
}
[Fact]
@@ -77,8 +77,8 @@ public class GetHardwareRejectedFunctionTests
Assert.Equal(4, totalRawReports);
Assert.Equal(2, aggregated.Count);
var dell = ToDynamic(aggregated.First(a => ((string)ToDynamic(a).manufacturer).Equals("Dell", StringComparison.OrdinalIgnoreCase)));
Assert.Equal(3, (int)dell.attemptCount);
var dell = aggregated.First(a => a.Manufacturer.Equals("Dell", StringComparison.OrdinalIgnoreCase));
Assert.Equal(3, (int)dell.AttemptCount);
}
[Fact]
@@ -94,9 +94,9 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(2, (int)item.uniqueSerials);
Assert.Equal(4, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(2, (int)item.UniqueSerials);
Assert.Equal(4, (int)item.AttemptCount);
}
[Fact]
@@ -108,10 +108,10 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
var samples = (IEnumerable<object>)item.sampleSerialNumbers;
var item = aggregated[0];
var samples = (IEnumerable<object>)item.SampleSerialNumbers;
Assert.Equal(5, samples.Count());
Assert.Equal(8, (int)item.uniqueSerials);
Assert.Equal(8, (int)item.UniqueSerials);
}
// =========================================================================
@@ -134,9 +134,9 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(t1, (DateTime)item.firstSeen);
Assert.Equal(t3, (DateTime)item.lastSeen);
var item = aggregated[0];
Assert.Equal(t1, (DateTime)item.FirstSeen);
Assert.Equal(t3, (DateTime)item.LastSeen);
}
[Fact]
@@ -152,12 +152,12 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
var first = ToDynamic(aggregated[0]);
var second = ToDynamic(aggregated[1]);
var third = ToDynamic(aggregated[2]);
var first = aggregated[0];
var second = aggregated[1];
var third = aggregated[2];
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
}
// =========================================================================
@@ -176,10 +176,10 @@ public class GetHardwareRejectedFunctionTests
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal("", (string)item.manufacturer);
Assert.Equal("", (string)item.model);
Assert.Equal(2, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal("", (string)item.Manufacturer);
Assert.Equal("", (string)item.Model);
Assert.Equal(2, (int)item.AttemptCount);
}
// =========================================================================
@@ -199,14 +199,4 @@ public class GetHardwareRejectedFunctionTests
AgentTimestamp = ingestedAt,
};
private static dynamic ToDynamic(object obj)
{
var type = obj.GetType();
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
private static JsonElement Serialize(TenantConfiguration config)
{
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
var json = JsonSerializer.Serialize(payload);
var json = TestWire.Serialize(payload);
return JsonDocument.Parse(json).RootElement;
}
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
"isTrial",
"showScriptOutput",
"trialAvailable",
"trialExpiresUtc",
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
};
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("secret-sas-token", json);
Assert.DoesNotContain("secret-team-hook", json);
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
[Fact]
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
{
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
Assert.DoesNotContain("secret-contact", json);
@@ -60,8 +60,8 @@ public class GetTpmPssUnsupportedFunctionTests
Assert.Single(aggregated);
Assert.Equal(3, totalRawReports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(3, (int)item.AttemptCount);
}
[Fact]
@@ -91,10 +91,10 @@ public class GetTpmPssUnsupportedFunctionTests
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal("Lenovo", (string)item.manufacturer);
Assert.Equal("ThinkCentre M720q", (string)item.model);
Assert.Equal("SN1", (string)item.serialNumber);
var item = aggregated[0];
Assert.Equal("Lenovo", (string)item.Manufacturer);
Assert.Equal("ThinkCentre M720q", (string)item.Model);
Assert.Equal("SN1", (string)item.SerialNumber);
}
// =========================================================================
@@ -117,9 +117,9 @@ public class GetTpmPssUnsupportedFunctionTests
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(t1, (DateTime)item.firstSeen);
Assert.Equal(t3, (DateTime)item.lastSeen);
var item = aggregated[0];
Assert.Equal(t1, (DateTime)item.FirstSeen);
Assert.Equal(t3, (DateTime)item.LastSeen);
}
[Fact]
@@ -135,12 +135,12 @@ public class GetTpmPssUnsupportedFunctionTests
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
var first = ToDynamic(aggregated[0]);
var second = ToDynamic(aggregated[1]);
var third = ToDynamic(aggregated[2]);
var first = aggregated[0];
var second = aggregated[1];
var third = aggregated[2];
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
}
// =========================================================================
@@ -160,8 +160,8 @@ public class GetTpmPssUnsupportedFunctionTests
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
var item = aggregated[0];
Assert.Equal(3, (int)item.AttemptCount);
}
// =========================================================================
@@ -181,14 +181,4 @@ public class GetTpmPssUnsupportedFunctionTests
AgentTimestamp = ingestedAt,
};
private static dynamic ToDynamic(object obj)
{
var type = obj.GetType();
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -0,0 +1,212 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Graph function folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class GraphWireParityTests
{
// ---- GetScriptDisplayNames -----------------------------------------------------------
[Fact]
public void GetScriptDisplayNamesResponse_matches_the_resolved_shape_with_malformed_tokens()
{
// Unresolved refs keep their key with an explicit null VALUE — dictionary values are
// not subject to WhenWritingNull (only properties are), on both sides.
var payload = new Dictionary<string, string?>
{
["Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001"] = "Install VPN client",
["Remediation:2eab4b63-5d2f-4c8b-8b12-52f3aaaa1002"] = null,
};
var malformed = new List<string> { "NotARef", "Platform:" };
AssertParity(
new
{
refs = payload,
malformed = malformed.Count > 0 ? (object)malformed : null,
},
new GetScriptDisplayNamesResponse
{
Refs = payload,
Malformed = malformed,
});
}
[Fact]
public void GetScriptDisplayNamesResponse_omits_malformed_when_all_tokens_parsed()
{
var payload = new Dictionary<string, string?>
{
["Platform:3fbc5c74-6e30-4d9c-9c23-52f3aaaa1003"] = "Set timezone",
};
var malformed = new List<string>();
AssertParity(
new
{
refs = payload,
malformed = malformed.Count > 0 ? (object)malformed : null,
},
new GetScriptDisplayNamesResponse
{
Refs = payload,
Malformed = malformed.Count > 0 ? malformed : null,
});
}
[Fact]
public void GetScriptDisplayNamesResponse_matches_the_empty_body_early_exit_shape()
{
// Old early-exit sites (empty body / empty refs list) never declared a malformed key
// at all: `new { refs = new Dictionary<string, string?>() }`. The typed side leaves
// Malformed null so WhenWritingNull removes the key identically.
AssertParity(
new { refs = new Dictionary<string, string?>() },
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
}
[Fact]
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
{
// ApiJsonOptions sets PropertyNamingPolicy = CamelCase but NO DictionaryKeyPolicy:
// property names camel-case, dictionary keys (canonical script refs with a PascalCase
// type prefix) serialize verbatim. Pin that asymmetry so a future serializer change
// (e.g. adding DictionaryKeyPolicy = CamelCase) fails loudly here.
var typed = new GetScriptDisplayNamesResponse
{
Refs = new Dictionary<string, string?>
{
["Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004"] = "Map drives",
["Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005"] = null,
},
};
var json = JsonSerializer.Serialize(typed, ApiJsonOptions.Create());
Assert.Contains("\"refs\":", json); // property name IS camel-cased
Assert.Contains("\"Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004\"", json);
Assert.Contains("\"Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005\":null", json);
Assert.DoesNotContain("\"platform:", json);
Assert.DoesNotContain("\"remediation:", json);
}
// ---- GetGraphPermissionsStatus -------------------------------------------------------
[Fact]
public void GetGraphPermissionsStatusResponse_matches_the_authoritative_shape()
{
var clientId = "6cef8fa7-9163-40cf-af56-52f3aaaa1006";
var grantedRoles = new[] { "DeviceManagementConfiguration.Read.All" };
var features = new List<GraphFeatureStatusItem>
{
new GraphFeatureStatusItem
{
Name = "ScriptDisplayNames",
Granted = true,
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
},
new GraphFeatureStatusItem
{
Name = "W365CloudPcValidation",
Granted = false,
RequiredPermissions = new[] { "CloudPC.Read.All" },
},
};
var anonymousFeatures = new List<object>
{
new
{
name = "ScriptDisplayNames",
granted = (bool?)true,
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
},
new
{
name = "W365CloudPcValidation",
granted = (bool?)false,
requiredPermissions = (IReadOnlyList<string>)new[] { "CloudPC.Read.All" },
},
};
AssertParity(
new
{
clientId,
isTransient = false,
grantedRoles,
features = anonymousFeatures,
},
new GetGraphPermissionsStatusResponse
{
ClientId = clientId,
IsTransient = false,
GrantedRoles = grantedRoles,
Features = features,
});
}
[Fact]
public void GetGraphPermissionsStatusResponse_omits_granted_on_a_transient_snapshot()
{
// Old site: `granted = snapshot.IsTransient ? (bool?)null : ...` — WhenWritingNull
// removed the key per feature row; the typed bool? must vanish identically.
var anonymousFeatures = new List<object>
{
new
{
name = "ScriptDisplayNames",
granted = (bool?)null,
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
},
};
var typedFeatures = new List<GraphFeatureStatusItem>
{
new GraphFeatureStatusItem
{
Name = "ScriptDisplayNames",
Granted = null,
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
},
};
AssertParity(
new
{
clientId = string.Empty,
isTransient = true,
grantedRoles = Array.Empty<string>(),
features = anonymousFeatures,
},
new GetGraphPermissionsStatusResponse
{
ClientId = string.Empty,
IsTransient = true,
GrantedRoles = Array.Empty<string>(),
Features = typedFeatures,
});
}
// ---- RefreshGraphPermissions ---------------------------------------------------------
[Fact]
public void RefreshGraphPermissions_success_body_matches_SuccessOnlyResponse()
{
AssertParity(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,752 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Infrastructure / Global / Notifications / Bootstrap /
/// Diagnostics function folders plus McpQuotaEnforcementMiddleware (anonymous-object →
/// typed-DTO migration). Each fact serializes the OLD anonymous literal exactly as it stood
/// at the call site (copied from the pre-migration code, filled with realistic sample
/// values) against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class InfrastructureWireParityTests
{
// ---- AuthFunction: auth/me success body ----------------------------------------------
// Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse.
[Fact]
public void AuthMeResponse_matches_the_success_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var upn = "admin@contoso.com";
var displayName = "Admin User";
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
AssertParity(
new
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin = false,
isGlobalReader = false,
isTenantAdmin = true,
isDelegated = true,
delegatedTenantIds = managedTenantIds,
role = "Admin",
canManageBootstrapTokens = true,
hasMcpAccess = true,
homedApp = "primary",
bootstrapTokenEnabled = true,
unrestrictedModeEnabled = false
},
new AuthMeResponse
{
TenantId = tenantId,
Upn = upn,
DisplayName = displayName,
ObjectId = objectId,
IsGlobalAdmin = false,
IsGlobalReader = false,
IsTenantAdmin = true,
IsDelegated = true,
DelegatedTenantIds = managedTenantIds,
Role = "Admin",
CanManageBootstrapTokens = true,
HasMcpAccess = true,
HomedApp = "primary",
BootstrapTokenEnabled = true,
UnrestrictedModeEnabled = false
});
}
[Fact]
public void AuthMeResponse_omits_a_null_role()
{
// A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal,
// so the key vanished — the DTO must vanish it identically.
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var upn = "reader@contoso.com";
var displayName = "Reader User";
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
string? role = null;
AssertParity(
new
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin = false,
isGlobalReader = true,
isTenantAdmin = false,
isDelegated = false,
delegatedTenantIds = Array.Empty<string>(),
role,
canManageBootstrapTokens = false,
hasMcpAccess = true,
homedApp = "legacy",
bootstrapTokenEnabled = false,
unrestrictedModeEnabled = false
},
new AuthMeResponse
{
TenantId = tenantId,
Upn = upn,
DisplayName = displayName,
ObjectId = objectId,
IsGlobalAdmin = false,
IsGlobalReader = true,
IsTenantAdmin = false,
IsDelegated = false,
DelegatedTenantIds = Array.Empty<string>(),
Role = null,
CanManageBootstrapTokens = false,
HasMcpAccess = true,
HomedApp = "legacy",
BootstrapTokenEnabled = false,
UnrestrictedModeEnabled = false
});
}
// ---- AuthFunction: IsGlobalAdmin -----------------------------------------------------
[Fact]
public void IsGlobalAdminResponse_matches_the_flag_shape()
{
var isAdmin = true;
string? upn = "admin@contoso.com";
AssertParity(
new { isGlobalAdmin = isAdmin, upn },
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = upn });
}
[Fact]
public void IsGlobalAdminResponse_omits_a_null_upn()
{
var isAdmin = false;
string? upn = null;
AssertParity(
new { isGlobalAdmin = isAdmin, upn },
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null });
}
// ---- McpUserFunction: CheckMcpAccess (auth/mcp) --------------------------------------
// Left side: the exact Dictionary<string, object?> the site built before the DTO —
// insertion order == wire order, conditional keys only present when the tier applies.
[Fact]
public void CheckMcpAccessResponse_matches_the_plain_user_shape()
{
var payload = new Dictionary<string, object?>
{
["allowed"] = true,
["upn"] = "analyst@contoso.com",
["accessGrant"] = "whitelist",
["reason"] = "whitelisted",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = true,
Upn = "analyst@contoso.com",
AccessGrant = "whitelist",
Reason = "whitelisted",
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape()
{
var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" };
var payload = new Dictionary<string, object?>
{
["allowed"] = true,
["upn"] = "admin@contoso.com",
["accessGrant"] = "global-admin",
["reason"] = "platform role",
["isGlobalAdmin"] = true,
["globalRole"] = "GlobalAdmin",
["delegatedTenantIds"] = delegatedTenantIds,
["delegatedRole"] = "DelegatedAdmin",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = true,
Upn = "admin@contoso.com",
AccessGrant = "global-admin",
Reason = "platform role",
IsGlobalAdmin = true,
GlobalRole = "GlobalAdmin",
DelegatedTenantIds = delegatedTenantIds,
DelegatedRole = "DelegatedAdmin",
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_denied_shape()
{
// 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never
// appear as false — the DTO's null slot omits it exactly like the old dictionary did.
var payload = new Dictionary<string, object?>
{
["allowed"] = false,
["upn"] = "user@contoso.com",
["accessGrant"] = "",
["reason"] = "not whitelisted",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = false,
Upn = "user@contoso.com",
AccessGrant = "",
Reason = "not whitelisted",
IsGlobalAdmin = null,
GlobalRole = null,
DelegatedTenantIds = null,
DelegatedRole = null,
});
}
// ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ----------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the
// raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
// These pins fix the NEW shape exactly (key names, order, table-key absence).
[Fact]
public void GetGlobalAdminsResponse_pins_the_row_listing_shape()
{
var admins = new List<GlobalAdminRow>
{
new GlobalAdminRow
{
Upn = "admin@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc),
AddedBy = "root@fabrikam.com",
Role = "GlobalAdmin",
},
new GlobalAdminRow
{
Upn = "reader@fabrikam.com",
IsEnabled = false,
AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
Role = "GlobalReader",
},
};
var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins });
Assert.Equal(
"{\"admins\":["
+ "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"},"
+ "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}"
+ "]}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("timestamp", json);
}
[Fact]
public void AddGlobalAdminResponse_pins_the_created_row_shape()
{
var newAdmin = new GlobalAdminRow
{
Upn = "new.admin@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
Role = "GlobalAdmin",
};
Assert.Equal(
"{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}",
TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin }));
}
// ---- HealthCheckFunction: HealthCheck ------------------------------------------------
[Fact]
public void HealthCheckResponse_matches_the_liveness_shape()
{
var timestamp = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
AssertParity(
new
{
status = "healthy",
service = "Autopilot Monitor API",
timestamp,
version = "1.2.3",
commitHash = "abc1234",
buildUtc
},
new HealthCheckResponse
{
Status = "healthy",
Service = "Autopilot Monitor API",
Timestamp = timestamp,
Version = "1.2.3",
CommitHash = "abc1234",
BuildUtc = buildUtc
});
}
// ---- HealthCheckFunction: DetailedHealthCheck ----------------------------------------
[Fact]
public void DetailedHealthCheckResponse_matches_the_report_shape()
{
var timestamp = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
var visibleChecks = new List<HealthCheck>
{
new HealthCheck
{
Name = "Table Storage",
Description = "Round-trip against the sessions table",
Status = "healthy",
Message = "OK",
Details = new Dictionary<string, object> { ["latencyMs"] = 12 },
},
new HealthCheck
{
Name = "SignalR",
Description = "Service reachability",
Status = "degraded",
Message = "Slow response",
// Details stays null → the key vanishes inside the item on both sides.
},
};
AssertParity(
new
{
service = "Autopilot Monitor API",
timestamp,
overallStatus = "degraded",
checks = visibleChecks,
version = "1.2.3",
commitHash = "abc1234",
buildUtc
},
new DetailedHealthCheckResponse
{
Service = "Autopilot Monitor API",
Timestamp = timestamp,
OverallStatus = "degraded",
Checks = visibleChecks,
Version = "1.2.3",
CommitHash = "abc1234",
BuildUtc = buildUtc
});
}
// ---- HealthCheckFunction: McpHealthCheck ---------------------------------------------
[Fact]
public void McpHealthCheckResponse_matches_the_probe_shape()
{
var timestamp = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc);
var check = new HealthCheck
{
Name = "MCP Server",
Description = "Remote MCP server reachability",
Status = "healthy",
Message = "Responded in 480ms",
};
AssertParity(
new
{
timestamp,
check
},
new McpHealthCheckResponse
{
Timestamp = timestamp,
Check = check
});
}
// ---- McpUserFunction: GetMcpUsers ----------------------------------------------------
[Fact]
public void GetMcpUsersResponse_matches_the_whitelist_shape()
{
var users = new List<McpUserEntry>
{
new McpUserEntry
{
Upn = "analyst@contoso.com",
IsEnabled = true,
AddedAt = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
UsagePlan = "pro",
},
new McpUserEntry
{
Upn = "viewer@fabrikam.com",
IsEnabled = false,
AddedAt = new DateTime(2026, 8, 11, 8, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
// UsagePlan stays null → the key vanishes inside the item on both sides.
},
};
AssertParity(
new { policy = "Whitelist", users },
new GetMcpUsersResponse { Policy = "Whitelist", Users = users });
}
// ---- McpUserFunction: AddMcpUser -----------------------------------------------------
[Fact]
public void AddMcpUserResponse_matches_the_created_entry_shape()
{
var user = new McpUserEntry
{
Upn = "new.analyst@contoso.com",
IsEnabled = true,
AddedAt = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
};
AssertParity(
new { user },
new AddMcpUserResponse { User = user });
}
// ---- McpUserFunction: SetMcpUserUsagePlan --------------------------------------------
[Fact]
public void SetMcpUserUsagePlanResponse_matches_the_plan_shape()
{
var upn = "analyst@contoso.com";
string? usagePlan = "pro";
AssertParity(
new { upn, usagePlan = usagePlan ?? "(inherit)" },
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
}
[Fact]
public void SetMcpUserUsagePlanResponse_renders_the_inherit_fallback()
{
var upn = "analyst@contoso.com";
string? usagePlan = null;
AssertParity(
new { upn, usagePlan = usagePlan ?? "(inherit)" },
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
}
// ---- SignalRNegotiateFunction --------------------------------------------------------
[Fact]
public void SignalRNegotiateResponse_matches_the_negotiate_protocol_shape()
{
var url = "https://signalr.service.example/client/?hub=notifications";
var accessToken = "eyJhbGciOiJIUzI1NiJ9.sample.token";
AssertParity(
new { url, accessToken },
new SignalRNegotiateResponse { Url = url, AccessToken = accessToken });
}
// ---- SignalRAddToGroup / SignalRRemoveFromGroup --------------------------------------
[Fact]
public void AddToGroup_success_body_matches_SuccessMessageResponse()
{
var groupName = "tenant-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
AssertParity(
new
{
success = true,
message = $"Added to group {groupName}"
},
new SuccessMessageResponse
{
Success = true,
Message = $"Added to group {groupName}"
});
}
[Fact]
public void RemoveFromGroup_success_body_matches_SuccessMessageResponse()
{
var groupName = "session-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d-0b6f7a37-1111-4d61-9c93-0aa111111111";
AssertParity(
new
{
success = true,
message = $"Removed from group {groupName}"
},
new SuccessMessageResponse
{
Success = true,
Message = $"Removed from group {groupName}"
});
}
// ---- GlobalNotificationsFunction / TenantNotificationsFunction: list -----------------
[Fact]
public void NotificationListResponse_matches_the_active_notification_shape()
{
var notifications = new List<GlobalNotificationDto>
{
new GlobalNotificationDto
{
Id = "8f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
Type = "preview_signup",
Title = "New Tenant Signup",
Message = "Tenant 6a6a35a2 (contoso.com), UPN: admin@contoso.com",
Href = "/admin/tenants/management?tenantId=6a6a35a2",
CreatedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc),
},
new GlobalNotificationDto
{
Id = "8f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
Type = "info",
Title = "Maintenance",
Message = "Nightly cleanup completed",
// Href stays null → the key vanishes inside the item on both sides.
CreatedAt = new DateTime(2026, 8, 30, 4, 0, 0, DateTimeKind.Utc),
},
};
AssertParity(
new { success = true, notifications },
new NotificationListResponse { Success = true, Notifications = notifications });
}
// ---- Global/Tenant notifications: dismiss single -------------------------------------
[Fact]
public void DismissNotification_success_body_matches_SuccessOnlyResponse()
{
AssertParity(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
// ---- Global/Tenant notifications: dismiss-all ----------------------------------------
[Fact]
public void DismissAllNotificationsResponse_matches_the_count_shape()
{
var dismissedCount = 7;
AssertParity(
new { success = true, dismissedCount },
new DismissAllNotificationsResponse { Success = true, DismissedCount = dismissedCount });
}
// ---- RevokeBootstrapSessionFunction --------------------------------------------------
[Fact]
public void RevokeBootstrapSession_success_body_matches_SuccessMessageResponse()
{
AssertParity(
new { success = true, message = "Bootstrap session revoked" },
new SuccessMessageResponse { Success = true, Message = "Bootstrap session revoked" });
}
// ---- DiagnosticsDownloadTicketFunction -----------------------------------------------
[Fact]
public void DiagnosticsDownloadTicketResponse_matches_the_ticket_shape()
{
var ticket = "dGVzdC10aWNrZXQ=.sig==";
var expiresAt = new DateTime(2026, 8, 30, 15, 10, 0, DateTimeKind.Utc);
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-1111-4d61-9c93-0aa111111111/diagnostics.zip";
var destinationLabel = "Hosted";
long? sizeBytes = 1048576;
AssertParity(
new
{
success = true,
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
expiresAt,
blobName,
destination = destinationLabel,
sizeBytes,
},
new DiagnosticsDownloadTicketResponse
{
Success = true,
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
ExpiresAt = expiresAt,
BlobName = blobName,
Destination = destinationLabel,
SizeBytes = sizeBytes,
});
}
[Fact]
public void DiagnosticsDownloadTicketResponse_omits_a_null_sizeBytes()
{
var ticket = "dGVzdC10aWNrZXQy.sig==";
var expiresAt = new DateTime(2026, 8, 30, 15, 20, 0, DateTimeKind.Utc);
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-2222-4d61-9c93-0aa222222222/diagnostics.zip";
var destinationLabel = "CustomerSas";
long? sizeBytes = null;
AssertParity(
new
{
success = true,
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
expiresAt,
blobName,
destination = destinationLabel,
sizeBytes,
},
new DiagnosticsDownloadTicketResponse
{
Success = true,
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
ExpiresAt = expiresAt,
BlobName = blobName,
Destination = destinationLabel,
SizeBytes = null,
});
}
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
[Fact]
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
{
var decision = new McpQuotaDecision
{
Allowed = false,
Plan = "community",
Scope = "daily",
DailyLimit = 200,
MonthlyLimit = 2000,
DailyUsed = 200,
MonthlyUsed = 940,
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
};
AssertParity(
new
{
quotaExceeded = true,
plan = decision.Plan,
scope = decision.Scope,
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
},
new McpQuotaExceededResponse
{
QuotaExceeded = true,
Plan = decision.Plan,
Scope = decision.Scope,
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
});
}
[Fact]
public void McpQuotaExceededResponse_matches_the_monthly_blocked_shape()
{
var decision = new McpQuotaDecision
{
Allowed = false,
Plan = "pro",
Scope = "monthly",
DailyLimit = 1000,
MonthlyLimit = 10000,
DailyUsed = 120,
MonthlyUsed = 10000,
ResetUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc),
};
AssertParity(
new
{
quotaExceeded = true,
plan = decision.Plan,
scope = decision.Scope,
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
},
new McpQuotaExceededResponse
{
QuotaExceeded = true,
Plan = decision.Plan,
Scope = decision.Scope,
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
});
}
[Fact]
public void McpQuotaExceededResponse_omits_a_null_scope()
{
// Unreachable at the 429 site (a blocked decision always names the exceeded window),
// but the DTO slot is nullable — prove the key vanishes identically on both sides.
string? scope = null;
AssertParity(
new
{
quotaExceeded = true,
plan = "community",
scope,
limit = 200,
used = 200L,
resetUtc = "2026-08-31T00:00:00Z",
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
},
new McpQuotaExceededResponse
{
QuotaExceeded = true,
Plan = "community",
Scope = null,
Limit = 200,
Used = 200,
ResetUtc = "2026-08-31T00:00:00Z",
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -181,13 +181,7 @@ public class KillSwitchEvaluatorTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var evaluator = new KillSwitchEvaluator(
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
{
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
return JsonSerializer.SerializeToElement(payload);
return TestWire.SerializeToElement(payload);
}
[Fact]
@@ -0,0 +1,645 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Metrics function folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class MetricsWireParityTests
{
// ---- GetDeviceHistory ----------------------------------------------------------------
[Fact]
public void GetDeviceHistoryResponse_matches_the_history_shape()
{
DeviceHistory? history = new DeviceHistory
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SerialKey = "sn-0042",
SerialNumber = "SN-0042",
Manufacturer = "Fabrikam Inc.",
Model = "Latitude 9999",
Chain = new List<DeviceSessionRef>
{
new DeviceSessionRef
{
SessionId = "0b6f7a37-1111-4d61-9c93-0aa111111111",
StartedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
CompletedAt = new DateTime(2026, 8, 28, 10, 5, 0, DateTimeKind.Utc),
Status = "Failed",
EnrollmentType = "v1",
IsPreProvisioned = false,
DurationSeconds = 3900,
AdminMarked = false,
},
new DeviceSessionRef
{
SessionId = "0b6f7a37-2222-4d61-9c93-0aa222222222",
StartedAt = new DateTime(2026, 8, 28, 11, 0, 0, DateTimeKind.Utc),
CompletedAt = new DateTime(2026, 8, 28, 11, 50, 0, DateTimeKind.Utc),
Status = "Succeeded",
EnrollmentType = "v1",
IsPreProvisioned = false,
DurationSeconds = 3000,
AdminMarked = false,
},
},
CurrentJourneyAttempts = 2,
JourneyCount = 1,
JourneyVersion = 1,
LastUpdated = new DateTime(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc),
};
int? attemptNumber = 2;
AssertParity(
new { success = true, history, attemptNumber },
new GetDeviceHistoryResponse
{
Success = true,
History = history,
AttemptNumber = attemptNumber,
});
}
[Fact]
public void GetDeviceHistoryResponse_omits_a_null_history_and_attemptNumber()
{
DeviceHistory? history = null;
int? attemptNumber = null;
AssertParity(
new { success = true, history, attemptNumber },
new GetDeviceHistoryResponse
{
Success = true,
History = null,
AttemptNumber = null,
});
}
// ---- GetGeographicLocationSessions / GetGlobalGeographicLocationSessions -------------
private static LocationSessionRow SampleLocationRow(
string sessionId, DateTime? completedAt, int? durationSeconds) => new LocationSessionRow
{
SessionId = sessionId,
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SerialNumber = "SN-0042",
DeviceName = "DESKTOP-CONTOSO1",
Manufacturer = "Fabrikam Inc.",
Model = "Latitude 9999",
StartedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
CompletedAt = completedAt,
Status = SessionStatus.Succeeded,
FailureReason = string.Empty,
DurationSeconds = durationSeconds,
EnrollmentType = "v1",
GeoCountry = "DE",
GeoRegion = "Hessen",
GeoCity = "Frankfurt",
TotalAppCount = 7,
HasDoTelemetry = true,
DoPercentPeerCaching = 42.5,
};
[Fact]
public void GeographicLocationSessionsResponse_matches_the_full_shape()
{
var rows = new List<LocationSessionRow>
{
SampleLocationRow(
"3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
new DateTime(2026, 8, 30, 9, 10, 0, DateTimeKind.Utc),
4200),
};
AssertParity(
new { success = true, sessions = rows, totalCount = rows.Count },
new GeographicLocationSessionsResponse
{
Success = true,
Sessions = rows,
TotalCount = rows.Count,
});
}
[Fact]
public void GeographicLocationSessionsLeanResponse_matches_the_lean_projection_shape()
{
var r = SampleLocationRow(
"3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
new DateTime(2026, 8, 30, 9, 40, 0, DateTimeKind.Utc),
5100);
// Old site: ToLeanRow returned `object` — an anonymous projection of the row.
var lean = new List<object>
{
new
{
sessionId = r.SessionId,
tenantId = r.TenantId,
serialNumber = r.SerialNumber,
deviceName = r.DeviceName,
manufacturer = r.Manufacturer,
model = r.Model,
startedAt = r.StartedAt,
completedAt = r.CompletedAt,
status = r.Status,
failureReason = r.FailureReason,
durationSeconds = r.DurationSeconds,
enrollmentType = r.EnrollmentType,
geoCountry = r.GeoCountry,
geoCity = r.GeoCity,
totalAppCount = r.TotalAppCount,
hasDoTelemetry = r.HasDoTelemetry,
doPercentPeerCaching = r.DoPercentPeerCaching,
},
};
var leanTyped = new List<LocationSessionLeanRow>
{
new LocationSessionLeanRow
{
SessionId = r.SessionId,
TenantId = r.TenantId,
SerialNumber = r.SerialNumber,
DeviceName = r.DeviceName,
Manufacturer = r.Manufacturer,
Model = r.Model,
StartedAt = r.StartedAt,
CompletedAt = r.CompletedAt,
Status = r.Status,
FailureReason = r.FailureReason,
DurationSeconds = r.DurationSeconds,
EnrollmentType = r.EnrollmentType,
GeoCountry = r.GeoCountry,
GeoCity = r.GeoCity,
TotalAppCount = r.TotalAppCount,
HasDoTelemetry = r.HasDoTelemetry,
DoPercentPeerCaching = r.DoPercentPeerCaching,
},
};
AssertParity(
new { success = true, sessions = lean, totalCount = lean.Count },
new GeographicLocationSessionsLeanResponse
{
Success = true,
Sessions = leanTyped,
TotalCount = leanTyped.Count,
});
}
[Fact]
public void GeographicLocationSessionsLeanResponse_omits_null_completedAt_and_durationSeconds()
{
var r = SampleLocationRow("3f1c9d55-cccc-4e0e-8888-52f3aaaa0003", null, null);
var lean = new List<object>
{
new
{
sessionId = r.SessionId,
tenantId = r.TenantId,
serialNumber = r.SerialNumber,
deviceName = r.DeviceName,
manufacturer = r.Manufacturer,
model = r.Model,
startedAt = r.StartedAt,
completedAt = r.CompletedAt,
status = r.Status,
failureReason = r.FailureReason,
durationSeconds = r.DurationSeconds,
enrollmentType = r.EnrollmentType,
geoCountry = r.GeoCountry,
geoCity = r.GeoCity,
totalAppCount = r.TotalAppCount,
hasDoTelemetry = r.HasDoTelemetry,
doPercentPeerCaching = r.DoPercentPeerCaching,
},
};
var leanTyped = new List<LocationSessionLeanRow>
{
new LocationSessionLeanRow
{
SessionId = r.SessionId,
TenantId = r.TenantId,
SerialNumber = r.SerialNumber,
DeviceName = r.DeviceName,
Manufacturer = r.Manufacturer,
Model = r.Model,
StartedAt = r.StartedAt,
CompletedAt = null,
Status = r.Status,
FailureReason = r.FailureReason,
DurationSeconds = null,
EnrollmentType = r.EnrollmentType,
GeoCountry = r.GeoCountry,
GeoCity = r.GeoCity,
TotalAppCount = r.TotalAppCount,
HasDoTelemetry = r.HasDoTelemetry,
DoPercentPeerCaching = r.DoPercentPeerCaching,
},
};
AssertParity(
new { success = true, sessions = lean, totalCount = lean.Count },
new GeographicLocationSessionsLeanResponse
{
Success = true,
Sessions = leanTyped,
TotalCount = leanTyped.Count,
});
}
// ---- GetPlatformStats ----------------------------------------------------------------
[Fact]
public void GetPlatformStatsResponse_matches_the_zero_shape()
{
AssertParity(
new
{
totalEnrollments = 0,
totalUsers = 0,
totalTenants = 0,
uniqueDeviceModels = 0,
totalEventsProcessed = 0,
successfulEnrollments = 0,
issuesDetected = 0,
lastUpdated = (DateTime?)null
},
new GetPlatformStatsResponse
{
TotalEnrollments = 0,
TotalUsers = 0,
TotalTenants = 0,
// TotalSignedUpTenants stays null → the key is absent, exactly like the old
// literal that never declared it.
UniqueDeviceModels = 0,
TotalEventsProcessed = 0,
SuccessfulEnrollments = 0,
IssuesDetected = 0,
LastUpdated = null
});
}
[Fact]
public void GetPlatformStatsResponse_matches_the_computed_stats_shape()
{
var stats = new PlatformStats
{
TotalEnrollments = 123456,
TotalUsers = 789,
TotalTenants = 321,
TotalSignedUpTenants = 345,
UniqueDeviceModels = 210,
TotalEventsProcessed = 9876543,
SuccessfulEnrollments = 111111,
IssuesDetected = 2222,
};
AssertParity(
new
{
totalEnrollments = stats.TotalEnrollments,
totalUsers = stats.TotalUsers,
totalTenants = stats.TotalTenants,
totalSignedUpTenants = stats.TotalSignedUpTenants,
uniqueDeviceModels = stats.UniqueDeviceModels,
totalEventsProcessed = stats.TotalEventsProcessed,
successfulEnrollments = stats.SuccessfulEnrollments,
issuesDetected = stats.IssuesDetected
},
new GetPlatformStatsResponse
{
TotalEnrollments = stats.TotalEnrollments,
TotalUsers = stats.TotalUsers,
TotalTenants = stats.TotalTenants,
TotalSignedUpTenants = stats.TotalSignedUpTenants,
UniqueDeviceModels = stats.UniqueDeviceModels,
TotalEventsProcessed = stats.TotalEventsProcessed,
SuccessfulEnrollments = stats.SuccessfulEnrollments,
IssuesDetected = stats.IssuesDetected
// LastUpdated stays null → the key is absent, exactly like the old literal
// that never declared it.
});
}
// ---- GetSessionTimeAttribution -------------------------------------------------------
[Fact]
public void GetSessionTimeAttributionResponse_matches_the_breakdown_shape()
{
SessionTimeBreakdown? breakdown = new SessionTimeBreakdown
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004",
AttributionVersion = 2,
EventCountAtCompute = 250,
WallClockSeconds = 3600,
UnattributedSeconds = 120,
RebootSeconds = 90,
SleepSeconds = 0,
BlockingAppCount = 3,
EspAppsOccupancySeconds = 1500,
};
AssertParity(
new { success = true, breakdown },
new GetSessionTimeAttributionResponse
{
Success = true,
Breakdown = breakdown,
});
}
[Fact]
public void GetSessionTimeAttributionResponse_omits_a_null_breakdown()
{
SessionTimeBreakdown? breakdown = null;
AssertParity(
new { success = true, breakdown },
new GetSessionTimeAttributionResponse
{
Success = true,
Breakdown = null,
});
}
// ---- GetMyMcpUsage -------------------------------------------------------------------
private static List<UserUsageRecord> SampleUsageRecords(string userId) => new List<UserUsageRecord>
{
new UserUsageRecord
{
UserId = userId,
UserPrincipalName = "admin@contoso.com",
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
Endpoint = "get_session_summary",
Date = "20260830",
RequestCount = 12,
LastRequestAt = new DateTime(2026, 8, 30, 14, 30, 0, DateTimeKind.Utc),
},
};
[Fact]
public void GetMyMcpUsageResponse_matches_the_self_service_shape()
{
var userId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
string? upn = "admin@contoso.com";
string? usagePlan = "pro";
var effectivePlan = "pro";
var dailyLimit = 500;
var monthlyLimit = 10000;
long dailyUsed = 42;
long monthlyUsed = 950;
var resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
var records = SampleUsageRecords(userId);
AssertParity(
new
{
userId,
upn,
usagePlan,
effectivePlan,
quota = new
{
dailyLimit,
monthlyLimit,
dailyUsed,
monthlyUsed,
resetUtc
},
records
},
new GetMyMcpUsageResponse
{
UserId = userId,
Upn = upn,
UsagePlan = usagePlan,
EffectivePlan = effectivePlan,
Quota = new McpUsageQuotaNode
{
DailyLimit = dailyLimit,
MonthlyLimit = monthlyLimit,
DailyUsed = dailyUsed,
MonthlyUsed = monthlyUsed,
ResetUtc = resetUtc
},
Records = records
});
}
[Fact]
public void GetMyMcpUsageResponse_omits_null_upn_and_usagePlan()
{
var userId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0008";
string? upn = null;
string? usagePlan = null;
var effectivePlan = "free";
var records = new List<UserUsageRecord>();
AssertParity(
new
{
userId,
upn,
usagePlan,
effectivePlan,
quota = new
{
dailyLimit = 50,
monthlyLimit = 1000,
dailyUsed = 0L,
monthlyUsed = 0L,
resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
},
records
},
new GetMyMcpUsageResponse
{
UserId = userId,
Upn = null,
UsagePlan = null,
EffectivePlan = effectivePlan,
Quota = new McpUsageQuotaNode
{
DailyLimit = 50,
MonthlyLimit = 1000,
DailyUsed = 0,
MonthlyUsed = 0,
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
},
Records = records
});
}
// ---- GetMcpUserUsage -----------------------------------------------------------------
[Fact]
public void GetMcpUserUsageResponse_matches_the_user_usage_shape()
{
var userId = "8bb31d22-0003-4b7c-a1d2-52f3aaaa0009";
var records = SampleUsageRecords(userId);
AssertParity(
new { userId, records },
new GetMcpUserUsageResponse { UserId = userId, Records = records });
}
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
}
[Fact]
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var records = new List<UserUsageRecord>();
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
}
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var summaries = new List<UserUsageDailySummary>
{
new UserUsageDailySummary
{
Date = "20260830",
TenantId = tenantId,
TotalRequests = 321,
UniqueUsers = 4,
UniqueEndpoints = 11,
},
};
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
}
[Fact]
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var summaries = new List<UserUsageDailySummary>();
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
}
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
[Fact]
public void MetricsSummaryResponse_matches_the_tally_shape()
{
var days = 30;
// Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the
// MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical
// (incl. the windowDays duplicate on every item).
var summary = new List<object>
{
new
{
tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
totalSessions = 10,
succeeded = 6,
failed = 2,
inProgress = 1,
pending = 0,
stalled = 0,
awaitingUser = 0,
incomplete = 1,
other = 0,
failureRate = 25.0,
windowDays = days
},
};
AssertParity(
new { success = true, summary, windowDays = days },
new MetricsSummaryResponse
{
Success = true,
Summary = new List<MetricsSummaryTenantItem>
{
new MetricsSummaryTenantItem
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
TotalSessions = 10,
Succeeded = 6,
Failed = 2,
InProgress = 1,
Pending = 0,
Stalled = 0,
AwaitingUser = 0,
Incomplete = 1,
Other = 0,
FailureRate = 25.0,
WindowDays = days,
},
},
WindowDays = days,
});
}
// ---- GetRuleHitSessions --------------------------------------------------------------
[Fact]
public void GetRuleHitSessionsResponse_matches_the_hit_listing_shape()
{
var ruleId = "APP-017";
var days = 14;
var sessionIds = new List<string>
{
"add53f44-0005-4b7c-a1d2-52f3aaaa0011",
"bee64055-0006-4b7c-a1d2-52f3aaaa0012",
};
const int maxSessionIds = 2000;
AssertParity(
new
{
ruleId,
days,
sessionIds,
truncated = sessionIds.Count >= maxSessionIds
},
new GetRuleHitSessionsResponse
{
RuleId = ruleId,
Days = days,
SessionIds = sessionIds,
Truncated = sessionIds.Count >= maxSessionIds
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -585,13 +585,7 @@ public class TenantOffboardingHandlerDrainContractTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
@@ -451,13 +451,7 @@ public class TenantOffboardingHandlerOrderingTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
}
@@ -277,13 +277,7 @@ public class TenantOffboardingWorkerPoisonTests
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
return new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
return TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
}
}
@@ -1,120 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet against NEW anonymous-object API responses: <c>req.OkAsync(new { ... })</c> ships a
/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the
/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed
/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests);
/// the existing sites are frozen as a per-file baseline that may only SHRINK:
/// - a new anonymous OkAsync (new file, or count above baseline) fails,
/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed,
/// so the baseline always reflects reality and the debt is visible in the diff.
/// Typed object initializers (<c>OkAsync(new SomeResponse { ... })</c>) do not match.
/// </summary>
public class OkAsyncBaselineGuardTests
{
private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled);
/// <summary>
/// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project.
/// Only ever lower a count or delete an entry — never raise or add.
/// </summary>
private static readonly Dictionary<string, int> Baseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Admin/GetActiveUsersFunction.cs"] = 1,
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
["Functions/Admin/GetOpsEventsFunction.cs"] = 2,
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2,
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2,
["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1,
["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3,
["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1,
["Functions/Raw/QueryRawEventsFunction.cs"] = 2,
["Functions/Raw/QueryRawSessionsFunction.cs"] = 1,
["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1,
["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1,
["Functions/Reports/GetSessionReportsFunction.cs"] = 2,
["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1,
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1,
["Functions/Sessions/GetAllSessionsFunction.cs"] = 1,
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1,
["Functions/Sessions/GetSessionEventsFunction.cs"] = 2,
["Functions/Sessions/GetSessionFunction.cs"] = 1,
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
["Functions/Sessions/GetSessionStatsFunction.cs"] = 1,
["Functions/Sessions/GetSessionsFunction.cs"] = 1,
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1,
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsFunction.cs"] = 1,
};
[Fact]
public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline()
{
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
continue;
var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count;
if (count > 0)
actual[relative] = count;
}
var violations = new List<string>();
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
if (!Baseline.TryGetValue(file, out var allowed))
violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead.");
else if (count > allowed)
violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
}
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
// list stays truthful (and its shrink is visible in the diff).
foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
actual.TryGetValue(file, out var count);
if (count < allowed)
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down).");
}
Assert.True(violations.Count == 0,
"Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations));
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -0,0 +1,135 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models.Notifications;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers).
/// Before this, an alert carried only category/event/severity and a tenant GUID.
/// </summary>
public class OpsAlertPayloadTests
{
// ── Fact flattening ───────────────────────────────────────────────────
[Fact]
public void FlattenDetails_ProjectsTopLevelScalars()
{
var facts = OpsAlertDispatchService.FlattenDetails(
"""{"domainName":"contoso.example","daysLeft":3,"selfService":true}""");
Assert.Equal(3, facts.Count);
Assert.Equal("Domain Name", facts[0].Name);
Assert.Equal("contoso.example", facts[0].Value);
Assert.Equal("Days Left", facts[1].Name);
Assert.Equal("3", facts[1].Value);
Assert.Equal("Self Service", facts[2].Name);
Assert.Equal("true", facts[2].Value);
}
[Fact]
public void FlattenDetails_SkipsNullsAndNestedValues()
{
var facts = OpsAlertDispatchService.FlattenDetails(
"""{"domainName":null,"nested":{"a":1},"list":[1,2],"keep":"yes"}""");
Assert.Equal(new[] { "Keep" }, facts.Select(f => f.Name));
}
[Fact]
public void FlattenDetails_CapsFactCount()
{
var props = string.Join(",", Enumerable.Range(0, 30).Select(i => $"\"p{i}\":\"v\""));
Assert.Equal(OpsAlertDispatchService.MaxDetailFacts,
OpsAlertDispatchService.FlattenDetails("{" + props + "}").Count);
}
[Fact]
public void FlattenDetails_TruncatesLongValues()
{
var longValue = new string('x', 400);
var fact = Assert.Single(OpsAlertDispatchService.FlattenDetails($$"""{"note":"{{longValue}}"}"""));
Assert.Equal(OpsAlertDispatchService.MaxDetailValueLength + 1, fact.Value.Length); // + ellipsis
Assert.EndsWith("…", fact.Value);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-json")]
[InlineData("[1,2,3]")] // array root
[InlineData("\"a string\"")] // scalar root
public void FlattenDetails_MalformedOrNonObjectPayloadCostsNothing(string? json)
=> Assert.Empty(OpsAlertDispatchService.FlattenDetails(json));
[Theory]
[InlineData("domainName", "Domain Name")]
[InlineData("trialExpiresUtc", "Trial Expires Utc")]
[InlineData("id", "Id")]
[InlineData("URL", "URL")] // consecutive capitals are not split
public void Humanize_TitleCasesCamelCaseKeys(string input, string expected)
=> Assert.Equal(expected, OpsAlertDispatchService.Humanize(input));
// ── Generic JSON renderer contract ────────────────────────────────────
[Fact]
public void GenericJsonRenderer_EmbedsDataAsRealJsonNotAString()
{
var alert = new NotificationAlert
{
Title = "t", Summary = "s", EventType = "TenantTrialStarted",
DataJson = """{"domainName":"contoso.example","selfService":true}""",
};
var payload = JObject.Parse(new GenericJsonRenderer().RenderToJson(alert));
Assert.Equal(JTokenType.Object, payload["data"]!.Type);
Assert.Equal("contoso.example", payload["data"]!["domainName"]!.Value<string>());
Assert.True(payload["data"]!["selfService"]!.Value<bool>());
Assert.Equal("TenantTrialStarted", payload["eventType"]!.Value<string>());
}
[Fact]
public void GenericJsonRenderer_OmitsDataWhenAbsentOrUnusable()
{
var renderer = new GenericJsonRenderer();
Assert.Null(JObject.Parse(renderer.RenderToJson(
new NotificationAlert { Title = "t", Summary = "s" }))["data"]);
// Malformed or non-object payloads must not change the shape consumers parse against.
Assert.Null(JObject.Parse(renderer.RenderToJson(
new NotificationAlert { Title = "t", Summary = "s", DataJson = "not-json" }))["data"]);
Assert.Null(JObject.Parse(renderer.RenderToJson(
new NotificationAlert { Title = "t", Summary = "s", DataJson = "[1,2]" }))["data"]);
}
[Fact]
public void GenericJsonRenderer_SchemaVersionUnchangedByTheAdditiveDataKey()
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
// ── Telegram text carries the same information ────────────────────────
[Fact]
public void TelegramRendering_IncludesFlattenedFacts()
{
var alert = new NotificationAlert
{
Title = "Ops Alert: Tenant/TenantTrialStarted",
Summary = "Pro trial started",
Severity = NotificationSeverity.Info,
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
};
var text = TelegramNotificationService.RenderAlertText(alert);
Assert.Contains("Domain Name: contoso.example", text);
Assert.Contains("Pro trial started", text);
}
}
@@ -0,0 +1,42 @@
using System.Linq;
using System.Reflection;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The cross-category PAGED read fans out over a fixed category list. A category that is written
/// but not listed is invisible to every paged reader (MCP get_ops_events, the ops dashboard) while
/// the unpaged full-table path still returns it — a silent, one-sided blind spot. That happened to
/// Platform (Azure Monitor alerts via the ops alert webhook), which was recorded from day one and
/// never fanned out. These tests fail the build when a new OpsEventCategory constant is added
/// without extending the fan-out list.
/// </summary>
public class OpsEventCategoryCoverageTests
{
private static string[] DeclaredCategories() =>
typeof(OpsEventCategory)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
[Fact]
public void All_CoversEveryDeclaredCategoryConstant()
{
var declared = DeclaredCategories();
Assert.NotEmpty(declared);
Assert.Equal(declared.OrderBy(c => c), OpsEventCategory.All.OrderBy(c => c));
}
[Fact]
public void PagedFanOut_UsesTheFullCategoryVocabulary()
{
// Same array instance on purpose: the fan-out must not keep a private copy that can drift.
Assert.Same(OpsEventCategory.All, TableOpsEventRepository.AllCategories);
Assert.Contains(OpsEventCategory.Platform, TableOpsEventRepository.AllCategories);
}
}
@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the ops-events field filters (eventType / severity / minSeverity). Two properties matter:
/// (1) they are SERVER-SIDE — an operator asking for one event type must not pull a whole category
/// over the wire, and the paged fan-out must honour the exact same clauses as the single-category
/// and unpaged paths; (2) they are bound into the continuation fingerprint, so a token minted for
/// one filter cannot page a different one.
/// </summary>
public class OpsEventFieldFilterTests
{
private static NameValueCollection Query(params (string Key, string Value)[] pairs)
{
var q = new NameValueCollection();
foreach (var (k, v) in pairs) q[k] = v;
return q;
}
// ── Parsing ────────────────────────────────────────────────────────────────
[Fact]
public void Parse_NoFilterParams_YieldsEmptyFilters_AndNoExtras()
{
var result = OpsEventFilterRequest.Parse(Query(("category", "Agent")));
Assert.Null(result.Error);
Assert.True(result.Filters.IsEmpty);
Assert.Empty(OpsEventFilterRequest.ToExtras(result.Filters));
}
[Fact]
public void Parse_EventType_IsVerbatim_NotCaseFolded()
{
// Table Storage cannot fold case: whatever the caller names is what the store is asked for.
var result = OpsEventFilterRequest.Parse(Query(("eventType", "AgentEmergencyBreak")));
Assert.Null(result.Error);
Assert.Equal("AgentEmergencyBreak", result.Filters.EventType);
}
[Theory]
[InlineData("warning", "Warning")]
[InlineData("ERROR", "Error")]
[InlineData("Critical", "Critical")]
public void Parse_Severity_IsNormalisedToCanonicalCasing(string input, string expected)
{
// Without normalisation a "?severity=warning" would produce a Severity eq 'warning'
// clause, which matches zero rows and reads like "nothing happened".
var result = OpsEventFilterRequest.Parse(Query(("severity", input)));
Assert.Null(result.Error);
Assert.Equal(expected, result.Filters.Severity);
}
[Fact]
public void Parse_UnknownSeverity_IsRejected_NotSilentlyDropped()
{
var severity = OpsEventFilterRequest.Parse(Query(("severity", "Warn")));
var minSeverity = OpsEventFilterRequest.Parse(Query(("minSeverity", "sev2")));
Assert.NotNull(severity.Error);
Assert.Contains("severity must be one of", severity.Error);
Assert.Contains("Warn", severity.Error);
Assert.NotNull(minSeverity.Error);
Assert.Contains("minSeverity must be one of", minSeverity.Error);
}
[Fact]
public void ToExtras_EmitsNormalisedValues_InFixedOrder()
{
// Fixed order + normalised values: the fingerprint minted here must match the one
// recomputed when the echoed nextLink params come back on the follow-up request.
var result = OpsEventFilterRequest.Parse(Query(
("severity", "error"), ("eventType", "AgentEmergencyBreak"), ("minSeverity", "warning")));
var extras = OpsEventFilterRequest.ToExtras(result.Filters);
Assert.Equal(new[] { "eventType", "severity", "minSeverity" }, extras.Select(e => e.Key).ToArray());
Assert.Equal(new[] { "AgentEmergencyBreak", "Error", "Warning" }, extras.Select(e => e.Value).ToArray());
}
// ── Severity ladder ────────────────────────────────────────────────────────
[Fact]
public void SeverityLadder_RanksInfoLowest_CriticalHighest()
{
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Info) < OpsEventSeverity.Rank(OpsEventSeverity.Warning));
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Warning) < OpsEventSeverity.Rank(OpsEventSeverity.Error));
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Error) < OpsEventSeverity.Rank(OpsEventSeverity.Critical));
Assert.Equal(-1, OpsEventSeverity.Rank("Warn"));
Assert.Equal(-1, OpsEventSeverity.Rank(null));
}
[Fact]
public void AtOrAbove_ReturnsTheThresholdAndEverythingAboveIt()
{
Assert.Equal(new[] { "Warning", "Error", "Critical" }, OpsEventSeverity.AtOrAbove("Warning").ToArray());
Assert.Equal(new[] { "Critical" }, OpsEventSeverity.AtOrAbove("Critical").ToArray());
Assert.Equal(OpsEventSeverity.All, OpsEventSeverity.AtOrAbove("Info").ToArray());
Assert.Empty(OpsEventSeverity.AtOrAbove("Warn"));
}
// ── Clause building (both builders) ────────────────────────────────────────
[Fact]
public void BuildFilter_EmitsServerSideClauses_ForEveryField()
{
var f = TableOpsEventRepository.BuildFilter(
"Agent", null, null,
new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", Severity = "Error" });
Assert.NotNull(f);
Assert.Contains("EventType eq 'AgentEmergencyBreak'", f);
Assert.Contains("Severity eq 'Error'", f);
}
[Fact]
public void BuildFilter_MinSeverity_ExpandsToOrSet_NotALexicographicRange()
{
// "Critical" < "Error" < "Info" < "Warning" lexicographically — a ge comparison would
// silently return the wrong rows, so the threshold has to become an OR-set.
var f = TableOpsEventRepository.BuildFilter(
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Warning" });
Assert.NotNull(f);
Assert.Contains("(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')", f);
Assert.DoesNotContain("Severity ge", f);
Assert.DoesNotContain("Severity eq 'Info'", f);
}
[Fact]
public void BuildFilter_MinSeverityInfo_EmitsNoClause_ItIsTheFloor()
{
var f = TableOpsEventRepository.BuildFilter(
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Info" });
Assert.Equal("PartitionKey eq 'Agent'", f);
}
[Fact]
public void BuildFilter_NoFilters_IsUnchanged()
{
// Regression guard: the pre-existing filter surface must not shift when no field filter
// is named — every stored continuation token depends on the same rows coming back.
Assert.Equal(
TableOpsEventRepository.BuildFilter("Agent", null, null),
TableOpsEventRepository.BuildFilter("Agent", null, null, new OpsEventQueryFilters()));
Assert.Null(TableOpsEventRepository.BuildFilter(null, null, null, new OpsEventQueryFilters()));
}
[Fact]
public void BuildFilter_EscapesQuotes_SoAFilterCannotBreakOutOfItsLiteral()
{
var f = TableOpsEventRepository.BuildFilter(
null, null, null, new OpsEventQueryFilters { EventType = "Agent'Break" });
Assert.Equal("EventType eq 'Agent''Break'", f);
}
[Fact]
public void FanOutBuilder_HonoursTheSameFieldClauses_AsTheSingleCategoryBuilder()
{
// The all-categories paged path builds its own filter (it adds the per-partition RowKey
// bound). If it skipped the field filters, "no category named" would silently return
// unfiltered rows — the exact bug this test exists to prevent.
var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" };
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters);
Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut);
Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut);
Assert.Contains("RowKey gt '0009'", fanOut);
}
// ── Continuation fingerprint ───────────────────────────────────────────────
[Fact]
public void Fingerprint_BindsTheFieldFilters_SoATokenCannotCrossFilters()
{
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
var to = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
const string caller = "11111111-1111-1111-1111-111111111111";
string Fp(params (string Key, string Value)[] pairs) => DateWindowPagination.Fingerprint(
scope: "ops-events", callerTenantId: caller, dateFrom: from, dateTo: to,
extras: OpsEventFilterRequest.ToExtras(OpsEventFilterRequest.Parse(Query(pairs)).Filters));
var unfiltered = Fp();
var byType = Fp(("eventType", "AgentEmergencyBreak"));
var byOtherType = Fp(("eventType", "AgentTimeout"));
var byMinSeverity = Fp(("minSeverity", "Error"));
Assert.NotEqual(unfiltered, byType);
Assert.NotEqual(byType, byOtherType);
Assert.NotEqual(unfiltered, byMinSeverity);
// Casing is normalised before fingerprinting, so the same query in two spellings
// shares one token instead of minting two that each reject the other.
Assert.Equal(byMinSeverity, Fp(("minSeverity", "error")));
}
[Fact]
public void Fingerprint_WithNoFieldFilters_MatchesThePreFilterToken()
{
// Backward compatibility: tokens minted before the field filters existed carry only the
// category/tenantId extras. Appending the (empty) field extras must not change the hash,
// or every in-flight pagination would break on deploy.
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
const string caller = "11111111-1111-1111-1111-111111111111";
var legacyExtras = new List<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("category", "Agent"),
};
var withEmptyFilters = new List<KeyValuePair<string, string?>>(legacyExtras);
withEmptyFilters.AddRange(OpsEventFilterRequest.ToExtras(new OpsEventQueryFilters()));
Assert.Equal(
DateWindowPagination.Fingerprint("ops-events", caller, from, null, legacyExtras),
DateWindowPagination.Fingerprint("ops-events", caller, from, null, withEmptyFilters));
}
}
@@ -1,13 +1,17 @@
using System.Reflection;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.DataAccess;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Every ops event type the backend can write must be selectable in the portal's alert-rule
/// editor (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>) — the "dual register" that
/// used to be a memory-only convention. A type written but not listed cannot be routed to a
/// Telegram/webhook rule, which is exactly how a new Warning stays silent.
/// Every ops event type the backend can write must be (a) declared in the canonical
/// <see cref="OpsEventTypes"/> vocabulary and (b) selectable in the portal's alert-rule editor
/// (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>). A type written but not declared is
/// invisible to every consumer that enumerates the vocabulary (the shared manifest, and through
/// it the MCP); a type declared but not listed in the editor cannot be routed to a channel, which
/// is exactly how a new Warning stays silent.
/// </summary>
public class OpsEventTypeDualRegisterTests
{
@@ -20,27 +24,78 @@ public class OpsEventTypeDualRegisterTests
return dir!.FullName;
}
[Fact]
public void Every_backend_ops_event_type_is_listed_in_the_web_alert_rule_editor()
{
var root = RepoRoot();
var service = File.ReadAllText(Path.Combine(root, "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
var web = File.ReadAllText(Path.Combine(root, "src", "Web", "autopilot-monitor-web", "app", "admin", "components", "OpsAlertRulesSection.tsx"));
private static string OpsEventServiceSource() => File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
// WriteAsync(OpsEventCategory.X, "TypeName", ...) — the second argument is the wire type.
var backendTypes = Regex.Matches(service, @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
/// <summary>The vocabulary as the assembly declares it — reflection, not a source scan.</summary>
private static string[] DeclaredTypes() =>
typeof(OpsEventTypes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
[Fact]
public void All_listsEveryDeclaredConstant()
{
// OpsEventTypes.All is what the manifest and every enumerating consumer read.
Assert.Equal(
DeclaredTypes().OrderBy(t => t, StringComparer.Ordinal),
OpsEventTypes.All.OrderBy(t => t, StringComparer.Ordinal));
}
[Fact]
public void Every_write_site_uses_the_vocabulary_constant_not_a_raw_literal()
{
// The guard that keeps the reflection above honest: a bare string at a call site would
// write a real event type that the vocabulary — and therefore every consumer — never sees.
var literalWrites = Regex.Matches(
OpsEventServiceSource(),
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
.Select(m => m.Groups[1].Value)
.Distinct(StringComparer.Ordinal)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
Assert.NotEmpty(backendTypes);
Assert.True(literalWrites.Count == 0,
"OpsEventService writes these event types as raw string literals — declare them in " +
"OpsEventTypes and use the constant: " + string.Join(", ", literalWrites));
}
[Fact]
public void Every_write_site_names_a_declared_constant()
{
// Catches the other direction of the same seam: `OpsEventTypes.Foo` cannot compile unless
// it exists, so this asserts the extraction below still SEES the call sites — a refactor
// that renames WriteAsync would otherwise silently reduce this suite to vacuous truth.
var used = Regex.Matches(
OpsEventServiceSource(),
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*OpsEventTypes\.(\w+)")
.Select(m => m.Groups[1].Value)
.Distinct(StringComparer.Ordinal)
.ToList();
Assert.True(used.Count > 50,
$"Only {used.Count} ops-event write sites found — the extraction pattern is likely " +
"broken (WriteAsync renamed?), not the call sites.");
}
[Fact]
public void Every_declared_ops_event_type_is_listed_in_the_web_alert_rule_editor()
{
var web = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Web", "autopilot-monitor-web",
"app", "admin", "components", "OpsAlertRulesSection.tsx"));
var webTypes = Regex.Matches(web, @"""([A-Za-z0-9]+)""")
.Select(m => m.Groups[1].Value)
.ToHashSet(StringComparer.Ordinal);
var missing = backendTypes.Where(t => !webTypes.Contains(t)).ToList();
var missing = DeclaredTypes()
.Where(t => !webTypes.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
Assert.True(missing.Count == 0,
"Ops event types written by OpsEventService but missing from OPS_EVENT_TYPES in OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
"Ops event types declared in OpsEventTypes but missing from OPS_EVENT_TYPES in " +
"OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
}
}
@@ -0,0 +1,269 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
/// behavior survives the migration untouched, per-rule channel routing, and redaction of the
/// new secret-bearing field.
/// </summary>
public class OpsNotificationChannelsTests
{
private static AdminConfiguration LegacyConfig(
bool telegramEnabled = true, string telegramChat = "-1003785642894",
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
bool slackEnabled = false, string slackUrl = "")
=> new()
{
UpdatedBy = "test",
OpsAlertTelegramEnabled = telegramEnabled,
OpsAlertTelegramChatId = telegramChat,
OpsAlertTeamsEnabled = teamsEnabled,
OpsAlertTeamsWebhookUrl = teamsUrl,
OpsAlertSlackEnabled = slackEnabled,
OpsAlertSlackWebhookUrl = slackUrl,
};
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
private static OpsAlertRule Rule(string eventType, params string[] channelIds)
=> new()
{
EventType = eventType,
MinSeverity = OpsEventSeverity.Info,
Enabled = true,
NotifyChannelIds = channelIds.Length == 0 ? null : channelIds.ToList(),
};
private static OpsAlertRule PayloadRule(string eventType, params string[] channelIds)
{
var rule = Rule(eventType, channelIds);
rule.IncludePayload = true;
return rule;
}
/// <summary>Channels that receive the baseline alert (no payload) - the default for every rule.</summary>
private static IEnumerable<string> Plain(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
=> OpsAlertDispatchService.ResolveTargets(rules, channels).Plain.Select(c => c.Id);
/// <summary>Channels that receive the event structured payload (opted in per rule).</summary>
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
// ── Legacy synthesis ──────────────────────────────────────────────────
[Fact]
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
{
var channels = LegacyConfig().GetOpsNotificationChannels();
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
var telegram = channels[0];
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
Assert.Equal("-1003785642894", telegram.Url);
Assert.True(telegram.Enabled);
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
}
[Fact]
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
{
// A configured-but-disabled provider must stay disabled, or the migration would start
// sending to a destination the operator had deliberately switched off.
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
}
[Fact]
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
[Fact]
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
{
var config = LegacyConfig();
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
var channels = config.GetOpsNotificationChannels();
Assert.Single(channels);
Assert.Equal("sales", channels[0].Id);
}
// ── Rule → channel routing ────────────────────────────────────────────
[Fact]
public void ResolveTargets_RuleWithoutChannelIds_HitsEveryEnabledChannel()
{
var channels = new[] { Channel("a"), Channel("b") };
Assert.Equal(new[] { "a", "b" }, Plain(new[] { Rule("X") }, channels));
}
[Fact]
public void ResolveTargets_RuleWithChannelIds_HitsOnlyThose()
{
var channels = new[] { Channel("push"), Channel("sales") };
Assert.Equal(new[] { "sales" }, Plain(new[] { Rule("X", "sales") }, channels));
}
[Fact]
public void ResolveTargets_NeverSendsToADisabledChannel()
{
var channels = new[] { Channel("push"), Channel("sales", enabled: false) };
Assert.Empty(Plain(new[] { Rule("X", "sales") }, channels));
}
[Fact]
public void ResolveTargets_UnknownChannelIdDoesNotFallBackToBroadcast()
{
// A rule whose only channel was deleted must go nowhere. Falling back to "all" would
// silently leak e.g. a sales-only event into the operator push channel.
var channels = new[] { Channel("push") };
Assert.Empty(Plain(new[] { Rule("X", "deleted-channel") }, channels));
}
[Fact]
public void ResolveTargets_MultipleRules_UnionOfChannelsWithoutDuplicates()
{
var channels = new[] { Channel("push"), Channel("sales"), Channel("audit") };
Assert.Equal(new[] { "sales", "audit" },
Plain(new[] { Rule("X", "sales"), Rule("X", "sales", "audit") }, channels));
}
[Fact]
public void ResolveTargets_OneBroadcastRuleWins()
{
// If any matching rule is unrestricted, the union is everything - a narrower sibling rule
// cannot shrink an existing broadcast rule reach.
var channels = new[] { Channel("push"), Channel("sales") };
Assert.Equal(2, Plain(new[] { Rule("X", "sales"), Rule("X") }, channels).Count());
}
[Fact]
public void ResolveTargets_ChannelIdMatchIsCaseInsensitive()
{
var channels = new[] { Channel("Sales") };
Assert.Single(Plain(new[] { Rule("X", "sales") }, channels));
}
// ── Payload opt-in ────────────────────────────
[Fact]
public void ResolveTargets_PayloadIsOffUnlessTheRuleOptsIn()
{
// The default information level of every pre-existing rule: category, event, severity,
// tenant id. Nothing from the event payload.
var channels = new[] { Channel("push") };
Assert.Empty(WithPayload(new[] { Rule("X") }, channels));
Assert.Equal(new[] { "push" }, Plain(new[] { Rule("X") }, channels));
}
[Fact]
public void ResolveTargets_OptedInRuleSendsPayloadToItsChannelsOnly()
{
var channels = new[] { Channel("push"), Channel("sales") };
var rules = new[] { Rule("X", "push"), PayloadRule("X", "sales") };
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
Assert.Equal(new[] { "push" }, Plain(rules, channels));
}
[Fact]
public void ResolveTargets_ChannelInBothGroupsGetsThePayloadOnceOnly()
{
// One message per channel, and the explicit opt-in must not be cancelled by an
// unrelated sibling rule that happens to target the same channel.
var channels = new[] { Channel("sales") };
var rules = new[] { Rule("X", "sales"), PayloadRule("X", "sales") };
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
Assert.Empty(Plain(rules, channels));
}
[Fact]
public void ResolveTargets_OptedInBroadcastRuleReachesEveryEnabledChannel()
{
var channels = new[] { Channel("push"), Channel("sales") };
Assert.Equal(new[] { "push", "sales" }, WithPayload(new[] { PayloadRule("X") }, channels));
Assert.Empty(Plain(new[] { PayloadRule("X") }, channels));
}
// ── Redaction ─────────────────────────────────────────────────────────
[Fact]
public void RedactedCopyForReader_RedactsOpsChannelDestinationsButKeepsStructure()
{
var config = new AdminConfiguration { UpdatedBy = "t" };
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[]
{
new NotificationChannel
{
Id = "sales", Name = "Sales", ProviderType = 20,
Url = "https://sales.example/hook", SigningSecret = "s3cr3t-signing-key-value",
Enabled = true,
},
});
var redacted = NotificationChannel.ParseList(
config.RedactedCopyForReader().OpsNotificationChannelsJson).Single();
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.Url);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.SigningSecret);
Assert.Equal("Sales", redacted.Name); // structure stays readable
Assert.Equal(20, redacted.ProviderType);
}
[Fact]
public void RestoreRedactedList_RestoresSentinelDestinationById()
{
var stored = NotificationChannel.SerializeList(new[]
{
new NotificationChannel { Id = "sales", Name = "Sales", ProviderType = 20, Url = "https://sales.example/hook", Enabled = true },
});
var roundTripped = NotificationChannel.SerializeList(new[]
{
new NotificationChannel { Id = "sales", Name = "Sales renamed", ProviderType = 20, Url = Constants.RedactedSecretPlaceholder, Enabled = true },
});
var restored = NotificationChannel.ParseList(
NotificationChannel.RestoreRedactedList(roundTripped, stored)).Single();
Assert.Equal("https://sales.example/hook", restored.Url);
Assert.Equal("Sales renamed", restored.Name); // non-secret edits survive
}
[Fact]
public void RestoreRedactedList_WholeStringSentinelKeepsStoredList()
{
var stored = NotificationChannel.SerializeList(new[] { Channel("sales") });
Assert.Equal(stored, NotificationChannel.RestoreRedactedList(Constants.RedactedSecretPlaceholder, stored));
}
[Fact]
public void RestoreRedactedList_PassesThroughWhenNoSentinelPresent()
{
var candidate = NotificationChannel.SerializeList(new[] { Channel("sales") });
Assert.Equal(candidate, NotificationChannel.RestoreRedactedList(candidate, ""));
}
}
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>
{
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
});
@@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
var members = new List<TenantAdminEntity>();
var members = new List<TenantAdminRow>();
if (enabledMember)
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
return admins;
}
@@ -0,0 +1,140 @@
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Progress Portal success responses
/// (Functions/Progress/ProgressPortalFunction.cs). Each case serializes the OLD anonymous
/// literal (copied verbatim from the pre-migration code) against the NEW typed DTO with the
/// same values via <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
/// </summary>
public class ProgressWireParityTests
{
private static SessionSummary MakeSessionSummary() => new SessionSummary
{
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
TenantId = "11111111-2222-3333-4444-555555555555",
SerialNumber = "SN-CONTOSO-0042",
DeviceName = "CONTOSO-LT-0042",
Manufacturer = "Fabrikam Inc.",
Model = "Latitude 5440",
StartedAt = new DateTime(2026, 8, 30, 9, 15, 0, DateTimeKind.Utc),
CompletedAt = null,
CurrentPhase = 3,
CurrentPhaseDetail = "Device setup",
Status = SessionStatus.InProgress,
FailureReason = string.Empty
};
private static List<EnrollmentEvent> MakeEvents() => new List<EnrollmentEvent>
{
new EnrollmentEvent
{
EventId = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
TenantId = "11111111-2222-3333-4444-555555555555",
Timestamp = new DateTime(2026, 8, 30, 9, 16, 30, DateTimeKind.Utc),
EventType = "phase_transition"
},
new EnrollmentEvent
{
EventId = "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
TenantId = "11111111-2222-3333-4444-555555555555",
Timestamp = new DateTime(2026, 8, 30, 9, 18, 5, DateTimeKind.Utc),
EventType = "app_install_start"
}
};
// GET /api/progress/sessions/lookup — { success, found, session } with a matched session.
[Fact]
public void ProgressLookupSession_match_found()
{
var match = MakeSessionSummary();
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
found = match != null,
session = match
},
new ProgressLookupSessionResponse
{
Success = true,
Found = match != null,
Session = match
});
}
// GET /api/progress/sessions/lookup — no match: session is null and the key must vanish
// identically on both sides (WhenWritingNull).
[Fact]
public void ProgressLookupSession_no_match_omits_session_key()
{
SessionSummary? match = null;
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
found = match != null,
session = match
},
new ProgressLookupSessionResponse
{
Success = true,
Found = match != null,
Session = match
});
}
// GET /api/progress/sessions/{sessionId}/events — { success, sessionId, count, events }.
[Fact]
public void ProgressGetSessionEvents_success_with_events()
{
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
var events = MakeEvents();
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
sessionId = sessionId,
count = events.Count,
events = events
},
new ProgressGetSessionEventsResponse
{
Success = true,
SessionId = sessionId,
Count = events.Count,
Events = events
});
}
// Same shape with an empty event list — count 0 and an empty array on both sides.
[Fact]
public void ProgressGetSessionEvents_success_with_empty_list()
{
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
var events = new List<EnrollmentEvent>();
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
sessionId = sessionId,
count = events.Count,
events = events
},
new ProgressGetSessionEventsResponse
{
Success = true,
SessionId = sessionId,
Count = events.Count,
Events = events
});
}
}
@@ -0,0 +1,180 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Functions/Raw folder (anonymous-object → typed-DTO migration).
/// Each case serializes the OLD anonymous literal exactly as it stood in the pre-migration
/// code (keys, order, compile-time value types) against the NEW DTO with the same values.
/// Raw rows are PascalCase-verbatim table columns carried as dictionaries — the production
/// options set PropertyNamingPolicy (camelCase) but NO DictionaryKeyPolicy, so dictionary
/// keys must survive untouched on both sides; one case asserts that explicitly.
/// </summary>
public class RawWireParityTests
{
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
private static List<IReadOnlyDictionary<string, object?>> SampleEventRows() =>
new()
{
new Dictionary<string, object?>
{
["PartitionKey"] = "d3b7f8a2-1c4e-4f5a-9b6d-0e2f3a4b5c6d_8f1e2d3c-4b5a-6789-abcd-ef0123456789",
["RowKey"] = "0000000042",
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 12, 34, 56, TimeSpan.Zero),
["EventType"] = "esp_phase_change",
["Severity"] = 1,
["Source"] = "EspWatcher",
["Sequence"] = 42L,
["DataJson"] = "{\"phase\":\"DeviceSetup\"}",
},
};
// ── QueryRawEventsFunction — GET /api/raw/events + /api/global/raw/events ────────────
// Old literal (single-session site):
// new { tenantId, count = filtered.Count,
// events = RawEntityProjection.Project(filtered, fields), nextLink = singleNextLink }
// Old literal (cross-session site) has the identical shape:
// new { tenantId, count = events.Count,
// events = RawEntityProjection.Project(events, fields), nextLink }
[Fact]
public void QueryRawEvents_populated_matches_old_anonymous_shape()
{
var tenantId = "11111111-2222-3333-4444-555555555555";
var events = SampleEventRows();
var nextLink = "/api/raw/events?pageSize=50&continuation=abc123";
ApiResponseWireParityTests.AssertWireIdentical(
new { tenantId, count = events.Count, events, nextLink },
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
}
[Fact]
public void QueryRawEvents_null_tenantId_and_nextLink_keys_vanish_identically()
{
// Global scope without tenantId filter (cross-tenant) and last page: both keys
// disappear under WhenWritingNull — before and after the migration.
var tenantId = (string?)null;
var events = new List<IReadOnlyDictionary<string, object?>>();
var nextLink = (string?)null;
ApiResponseWireParityTests.AssertWireIdentical(
new { tenantId, count = events.Count, events, nextLink },
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
}
[Fact]
public void QueryRawEvents_dictionary_row_keys_stay_pascalcase_verbatim()
{
var events = SampleEventRows();
var json = JsonSerializer.Serialize(
new QueryRawEventsResponse { TenantId = "t", Count = events.Count, Events = events, NextLink = null },
WireOptions);
// Envelope properties are camelCased by PropertyNamingPolicy...
Assert.Contains("\"tenantId\"", json);
Assert.Contains("\"events\"", json);
// ...but the raw row keys are dictionary keys: no DictionaryKeyPolicy is set, so
// they must remain PascalCase-verbatim stored column names.
Assert.Contains("\"PartitionKey\"", json);
Assert.Contains("\"RowKey\"", json);
Assert.Contains("\"EventType\"", json);
Assert.Contains("\"DataJson\"", json);
Assert.DoesNotContain("\"partitionKey\"", json);
Assert.DoesNotContain("\"eventType\"", json);
}
// ── QueryRawSessionsFunction — GET /api/raw/sessions + /api/global/raw/sessions ──────
// Old literal:
// new { tenantId, count = page.Items.Count, sessions = sessionsPayload, nextLink }
[Fact]
public void QueryRawSessions_populated_matches_old_anonymous_shape()
{
var tenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
var sessions = new List<IReadOnlyDictionary<string, object?>>
{
new Dictionary<string, object?>
{
["PartitionKey"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
["RowKey"] = "9c8b7a65-4321-0fed-cba9-876543210fed",
["Status"] = "InProgress",
["SerialNumber"] = "VMware-42 00 11 22 33 44 55 66",
["AgentVersion"] = "2.14.0.0",
["DeviceName"] = "DESKTOP-CONTOSO1",
},
};
var nextLink = "/api/raw/sessions?pageSize=25&continuation=xyz789";
ApiResponseWireParityTests.AssertWireIdentical(
new { tenantId, count = sessions.Count, sessions, nextLink },
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
}
[Fact]
public void QueryRawSessions_null_tenantId_and_nextLink_keys_vanish_identically()
{
var tenantId = (string?)null;
var sessions = new List<IReadOnlyDictionary<string, object?>>();
var nextLink = (string?)null;
ApiResponseWireParityTests.AssertWireIdentical(
new { tenantId, count = sessions.Count, sessions, nextLink },
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
}
// ── TableQueryFunction.ListTables — GET /api/global/raw/tables ───────────────────────
// Old literal: new { count = tables.Count, tables }
[Fact]
public void ListRawTables_matches_old_anonymous_shape()
{
var tables = new List<string> { "Sessions", "SessionsIndex", "TenantConfiguration" };
ApiResponseWireParityTests.AssertWireIdentical(
new { count = tables.Count, tables },
new ListRawTablesResponse { Count = tables.Count, Tables = tables });
}
// ── TableQueryFunction.QueryTable — GET /api/global/raw/tables/{tableName} ───────────
// Old literal: new { table = actualTableName, count = entities.Count, entities, nextLink }
// (entities is List<Dictionary<string, object?>> at the call site.)
[Fact]
public void QueryRawTable_populated_matches_old_anonymous_shape()
{
var table = "SessionsIndex";
var entities = new List<Dictionary<string, object?>>
{
new()
{
["PartitionKey"] = "11111111-2222-3333-4444-555555555555",
["RowKey"] = "8f1e2d3c-4b5a-6789-abcd-ef0123456789",
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 8, 0, 0, TimeSpan.Zero),
["Status"] = "Succeeded",
["RebootCount"] = 2,
},
};
var nextLink = "/api/global/raw/tables/SessionsIndex?pageSize=100&continuation=tok456";
ApiResponseWireParityTests.AssertWireIdentical(
new { table, count = entities.Count, entities, nextLink },
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
}
[Fact]
public void QueryRawTable_null_nextLink_key_vanishes_identically()
{
var table = "OpsEvents";
var entities = new List<Dictionary<string, object?>>();
var nextLink = (string?)null;
ApiResponseWireParityTests.AssertWireIdentical(
new { table, count = entities.Count, entities, nextLink },
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
}
}
@@ -0,0 +1,184 @@
using System.Diagnostics;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// DoS hardening of the reducer-verification replay: device-uploaded signal payloads are
/// attacker-shaped (chunk-stored up to ~1 MB each, 5000 per request). The verifier must
/// (a) decode-fold-discard so peak memory is one signal, not the whole stream, and
/// (b) finish in seconds on a forged RealmJoin package stream because
/// <see cref="FactStringBounds"/> bounds the dedupe keys inside the engine. The
/// signal/transition count-imbalance semantics of the pre-streaming implementation are pinned
/// here as well because the streaming rewrite changed how that count is produced.
/// </summary>
public class ReducerVerifierBoundedReplayTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime T0 = new(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
private static string CurrentReducerVersion { get; } = new DecisionEngine().ReducerVersion;
private static DecisionSignal MakeSignal(long ordinal, DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload = null) =>
new(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: T0.AddSeconds(ordinal),
sourceOrigin: "replay-test",
evidence: new Evidence(EvidenceKind.Synthetic, $"replay:ord-{ordinal}", "test"),
payload: payload);
/// <summary>Fold <paramref name="sigs"/> through the live reducer and materialise the stored shapes.</summary>
private static (SignalRecord[] signals, DecisionTransitionRecord[] transitions) Materialise(IReadOnlyList<DecisionSignal> sigs)
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial(SessionId, TenantId);
var transitions = new DecisionTransitionRecord[sigs.Count];
var signals = new SignalRecord[sigs.Count];
for (var i = 0; i < sigs.Count; i++)
{
var step = engine.Reduce(state, sigs[i]);
state = step.NewState;
var t = step.Transition;
transitions[i] = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = t.StepIndex,
SessionTraceOrdinal = t.SessionTraceOrdinal,
SignalOrdinalRef = t.SignalOrdinalRef,
OccurredAtUtc = t.OccurredAtUtc,
Trigger = t.Trigger,
FromStage = t.FromStage.ToString(),
ToStage = t.ToStage.ToString(),
Taken = t.Taken,
DeadEndReason = t.DeadEndReason,
ReducerVersion = t.ReducerVersion,
PayloadJson = TransitionSerializer.Serialize(t),
};
signals[i] = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = sigs[i].SessionSignalOrdinal,
SessionTraceOrdinal = sigs[i].SessionTraceOrdinal,
Kind = sigs[i].Kind.ToString(),
KindSchemaVersion = sigs[i].KindSchemaVersion,
OccurredAtUtc = sigs[i].OccurredAtUtc,
SourceOrigin = sigs[i].SourceOrigin,
PayloadJson = SignalSerializer.Serialize(sigs[i]),
};
}
return (signals, transitions);
}
[Fact]
public void Forged_realmjoin_package_stream_replays_in_bounded_time_with_zero_divergence()
{
// Exploit shape: many equal-length package ids sharing all but the trailing digits.
// Bounded to 256 chars inside the engine they collapse to a single package row, so the
// per-signal dedupe scan compares one short key instead of 200 × 200 KB.
const int packageSignals = 250;
const int idLength = 200_000;
var sigs = new List<DecisionSignal>(packageSignals + 2)
{
MakeSignal(0, DecisionSignalKind.SessionStarted),
MakeSignal(1, DecisionSignalKind.RealmJoinDetected,
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" }),
};
for (var i = 0; i < packageSignals; i++)
{
sigs.Add(MakeSignal(2 + i, DecisionSignalKind.RealmJoinPackageStarted, new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = new string('p', idLength - 8) + i.ToString("D8"),
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
}));
}
var (signals, transitions) = Materialise(sigs);
Assert.All(signals.Skip(2), s => Assert.True(s.PayloadJson.Length > idLength));
var sw = Stopwatch.StartNew();
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
sw.Stop();
Assert.True(report.SemanticReplayPerformed);
Assert.Equal(0, report.TransitionDivergenceCount);
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_divergence");
// Deserialising 250 × 200 KB JSON blobs is the dominant (linear) cost here; the
// pre-fix quadratic scan alone was tens of seconds for this shape.
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), $"verification took {sw.Elapsed}");
}
[Fact]
public void Signal_surplus_beyond_the_journal_is_still_reported_as_count_imbalance()
{
var sigs = new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted),
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
};
var (signals, transitions) = Materialise(sigs);
var shortJournal = new[] { transitions[0], transitions[1] };
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, shortJournal, CurrentReducerVersion);
Assert.True(report.SemanticReplayPerformed);
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (3) != transition count (2)"));
}
[Fact]
public void Mid_stream_deserialisation_failure_truncates_replay_and_counts_only_decoded_signals()
{
var sigs = new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted),
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
};
var (signals, transitions) = Materialise(sigs);
signals[1].PayloadJson = "{not json";
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
Assert.True(report.SemanticReplayPerformed);
Assert.Contains(report.Issues, i => i.Kind == "replay_deserialization_error" && i.Message.Contains("ordinal 1"));
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (1) != transition count (3)"));
}
[Fact]
public void Head_deserialisation_failure_skips_replay_silently()
{
var (signals, transitions) = Materialise(new[] { MakeSignal(0, DecisionSignalKind.SessionStarted) });
signals[0].PayloadJson = "stub";
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
Assert.False(report.SemanticReplayPerformed);
Assert.Equal("deserialization_failure", report.SemanticReplaySkipReason);
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_deserialization_error");
}
// ---- Repository payload budget ---------------------------------------------------
[Fact]
public void Payload_budget_exhaustion_mirrors_the_repository_stop_condition()
{
SignalRecord Row(int chars) => new() { PayloadJson = new string('x', chars) };
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(10), Row(10) }, maxTotalPayloadChars: 100));
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(40) }, maxTotalPayloadChars: 100));
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(60) }, maxTotalPayloadChars: 100));
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(Array.Empty<SignalRecord>(), maxTotalPayloadChars: 100));
}
}
@@ -121,13 +121,7 @@ public class ReportAgentErrorFunctionTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
OpsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
}
@@ -0,0 +1,325 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Functions/Reports folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it appeared at the
/// call site (independent fixture, copied from the pre-migration code) and the NEW typed DTO
/// with the same values, asserting ordinally identical JSON via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
/// </summary>
public class ReportsWireParityTests
{
private static readonly DateTime T0 = new(2026, 8, 15, 10, 30, 0, DateTimeKind.Utc);
// =========================================================================
// GetDeviceNotRegisteredFunction — { success, aggregated, totalRawReports, dataQualityNotice }
// =========================================================================
[Fact]
public void DeviceNotRegistered_envelope_and_item_match_old_anonymous_shape()
{
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices.";
// Old anonymous item shape from BuildAggregatedResult (pre-migration), in a List<object>
// exactly as the old code produced it.
var oldAggregated = new List<object>
{
new
{
serialNumber = "GM18NHV3",
manufacturer = "Lenovo",
model = "ThinkPad T14",
isCloudPc = true,
attemptCount = 3,
firstSeen = T0,
lastSeen = T0.AddHours(2)
}
};
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
aggregated = oldAggregated,
totalRawReports = 3,
dataQualityNotice = notice
},
new DeviceNotRegisteredResponse
{
Success = true,
Aggregated = new List<DeviceNotRegisteredItem>
{
new()
{
SerialNumber = "GM18NHV3",
Manufacturer = "Lenovo",
Model = "ThinkPad T14",
IsCloudPc = true,
AttemptCount = 3,
FirstSeen = T0,
LastSeen = T0.AddHours(2)
}
},
TotalRawReports = 3,
DataQualityNotice = notice
});
}
// =========================================================================
// GetHardwareRejectedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
// =========================================================================
[Fact]
public void HardwareRejected_envelope_and_item_match_old_anonymous_shape()
{
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Manufacturer, model, and serial number values are self-reported by devices.";
var oldAggregated = new List<object>
{
new
{
manufacturer = "Dell Inc.",
model = "Latitude 5440",
attemptCount = 4,
uniqueSerials = 2,
firstSeen = T0,
lastSeen = T0.AddDays(1),
sampleSerialNumbers = new List<string> { "SN1", "SN2" }
}
};
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
aggregated = oldAggregated,
totalRawReports = 4,
dataQualityNotice = notice
},
new HardwareRejectedResponse
{
Success = true,
Aggregated = new List<HardwareRejectedItem>
{
new()
{
Manufacturer = "Dell Inc.",
Model = "Latitude 5440",
AttemptCount = 4,
UniqueSerials = 2,
FirstSeen = T0,
LastSeen = T0.AddDays(1),
SampleSerialNumbers = new List<string> { "SN1", "SN2" }
}
},
TotalRawReports = 4,
DataQualityNotice = notice
});
}
// =========================================================================
// GetTpmPssUnsupportedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
// =========================================================================
[Fact]
public void TpmPssUnsupported_envelope_and_item_match_old_anonymous_shape()
{
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices.";
var oldAggregated = new List<object>
{
new
{
serialNumber = "SN1",
manufacturer = "Lenovo",
model = "ThinkCentre M720q",
attemptCount = 3,
firstSeen = T0,
lastSeen = T0.AddHours(2)
}
};
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
aggregated = oldAggregated,
totalRawReports = 3,
dataQualityNotice = notice
},
new TpmPssUnsupportedResponse
{
Success = true,
Aggregated = new List<TpmPssUnsupportedItem>
{
new()
{
SerialNumber = "SN1",
Manufacturer = "Lenovo",
Model = "ThinkCentre M720q",
AttemptCount = 3,
FirstSeen = T0,
LastSeen = T0.AddHours(2)
}
},
TotalRawReports = 3,
DataQualityNotice = notice
});
}
// =========================================================================
// GetSessionReportsFunction — non-paged { success, count, reports } and
// paged { success, count, reports, nextLink } — one DTO (SessionReportListResponse)
// =========================================================================
private static List<SessionReportMetadata> MakeReports() =>
new()
{
new SessionReportMetadata
{
ReportId = "5f0d2c9a-1b3e-4f6a-8c7d-9e0f1a2b3c4d",
TenantId = "11111111-2222-3333-4444-555555555555",
SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Comment = "ESP stuck at device setup",
Email = "admin@contoso.com",
BlobName = "report_5f0d2c9a.zip",
SubmittedBy = "admin@contoso.com",
SubmittedAt = T0,
AdminNote = "",
ReportType = ReportTypes.Session,
DiagnosticsBlobName = null,
DiagnosticsCopyStatus = null
}
};
[Fact]
public void SessionReports_nonPaged_matches_old_anonymous_shape_without_nextLink_key()
{
var reports = MakeReports();
// Old non-paged literal had NO nextLink property; the typed DTO leaves NextLink null
// and WhenWritingNull must drop the key so both sides stay identical.
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, count = reports.Count, reports },
new SessionReportListResponse { Success = true, Count = reports.Count, Reports = reports });
}
[Fact]
public void SessionReports_paged_with_nextLink_matches_old_anonymous_shape()
{
var reports = MakeReports();
var nextLink = "/api/global/session-reports?pageSize=50&continuation=abc123";
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
count = reports.Count,
reports,
nextLink,
},
new SessionReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports,
NextLink = nextLink,
});
}
[Fact]
public void SessionReports_paged_lastPage_nextLink_null_drops_key_on_both_sides()
{
var reports = MakeReports();
string? nextLink = null;
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
count = reports.Count,
reports,
nextLink,
},
new SessionReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports,
NextLink = nextLink,
});
}
// =========================================================================
// GetDistressReportsFunction — { success, count, reports }
// =========================================================================
[Fact]
public void DistressReports_matches_old_anonymous_shape()
{
var reports = new List<DistressReportEntry>
{
new()
{
TenantId = "11111111-2222-3333-4444-555555555555",
ErrorType = "DeviceNotRegistered",
Manufacturer = "Lenovo",
Model = "ThinkPad T14",
SerialNumber = "GM18NHV3",
AgentVersion = "2.4.1.0",
HttpStatusCode = 403,
Message = "Device not found in registry",
AgentTimestamp = T0,
IngestedAt = T0.AddSeconds(5),
SourceIp = "203.0.113.10",
IsCloudPc = false
// Cert-context fields left null — WhenWritingNull drops them identically
// on both sides (same DistressReportEntry class serializes the items).
}
};
ApiResponseWireParityTests.AssertWireIdentical(
new
{
success = true,
count = reports.Count,
reports
},
new DistressReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports
});
}
// =========================================================================
// GetSessionReportDownloadUrlFunction — { success, downloadUrl }
// =========================================================================
[Fact]
public void SessionReportDownloadUrl_matches_old_anonymous_shape()
{
var downloadUrl = "https://storage.example.invalid/session-reports/report_5f0d2c9a.zip?sv=2024-11-04&sig=abc";
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, downloadUrl },
new SessionReportDownloadUrlResponse { Success = true, DownloadUrl = downloadUrl });
}
// =========================================================================
// UpdateSessionReportNoteFunction — { success } (reuses SuccessOnlyResponse)
// =========================================================================
[Fact]
public void UpdateSessionReportNote_success_matches_old_anonymous_shape()
{
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true },
new SuccessOnlyResponse { Success = true });
}
}
@@ -0,0 +1,365 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Rules function folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class RulesWireParityTests
{
// ---- GetAnalyzeRules / GetGlobalAnalyzeRules -----------------------------------------
[Fact]
public void AnalyzeRuleListResponse_matches_the_rules_listing_shape()
{
var rules = new List<AnalyzeRule>
{
new AnalyzeRule { RuleId = "ANALYZE-NET-001" },
new AnalyzeRule { RuleId = "ANALYZE-APP-017" },
};
AssertParity(
new { success = true, rules },
new AnalyzeRuleListResponse { Success = true, Rules = rules });
}
// ---- GetGatherRules / GetGlobalGatherRules -------------------------------------------
[Fact]
public void GatherRuleListResponse_matches_the_rules_listing_shape()
{
var rules = new List<GatherRule>
{
new GatherRule { RuleId = "GATHER-LOG-001", Author = "Contoso Admin" },
};
AssertParity(
new { success = true, rules },
new GatherRuleListResponse { Success = true, Rules = rules });
}
// ---- GetImeLogPatterns ---------------------------------------------------------------
[Fact]
public void ImeLogPatternListResponse_matches_the_patterns_listing_shape()
{
var patterns = new List<ImeLogPattern>
{
new ImeLogPattern { PatternId = "IME-PS-AGENT-OUTPUT" },
};
AssertParity(
new { success = true, patterns },
new ImeLogPatternListResponse { Success = true, Patterns = patterns });
}
// ---- Create/Update/Delete rule + UpdateImeLogPattern (dual success/message sites) ----
[Fact]
public void Rule_mutation_acknowledgements_match_SuccessMessageResponse_on_both_branches()
{
foreach (var success in new[] { true, false })
{
AssertParity(
new { success, message = success ? "Rule created" : "Failed to create rule" },
new SuccessMessageResponse { Success = success, Message = success ? "Rule created" : "Failed to create rule" });
AssertParity(
new { success, message = success ? "Rule updated" : "Failed to update rule" },
new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" });
AssertParity(
new { success, message = success ? "Rule deleted" : "Failed to delete rule" },
new SuccessMessageResponse { Success = success, Message = success ? "Rule deleted" : "Failed to delete rule" });
AssertParity(
new { success, message = success ? "Global pattern updated" : "Failed to update global pattern" },
new SuccessMessageResponse { Success = success, Message = success ? "Global pattern updated" : "Failed to update global pattern" });
}
}
// ---- CreateAnalyzeRuleFromTemplate ---------------------------------------------------
[Fact]
public void CreateAnalyzeRuleFromTemplateResponse_matches_the_created_rule_shape()
{
var newRule = new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-0001" };
AssertParity(
new { success = true, rule = newRule, message = "Custom rule created from template" },
new CreateAnalyzeRuleFromTemplateResponse { Success = true, Rule = newRule, Message = "Custom rule created from template" });
}
// ---- DryRunAnalyzeRule ---------------------------------------------------------------
[Fact]
public void DryRunAnalyzeRuleResponse_matches_the_dry_run_trace_shape()
{
var sessionId = "7c1e2a90-1111-4b7c-a1d2-52f3bbbb0001";
var result = new RuleDryRun
{
Verdict = RuleDryRunVerdict.Fired,
EventCount = 42,
BaseConfidence = 50,
FinalConfidence = 80,
ConfidenceThreshold = 60,
WouldMarkSessionAsFailed = false,
MatchedConditions = new Dictionary<string, object>
{
["timeout_seen"] = new { eventId = "evt-0001", value = "timeout" },
},
};
result.Conditions.Add(new RuleDryRunCondition
{
Signal = "timeout_seen",
Source = "event_data",
EventType = "app_install_failed",
Required = true,
Matched = true,
Evidence = new Dictionary<string, object> { ["field"] = "errorCode" },
MatchingEventCount = 3,
});
result.ConfidenceFactors.Add(new RuleDryRunFactor
{
Signal = "timeout_seen",
Condition = "count >= 2",
Weight = 30,
Matched = true,
});
AssertParity(
new { success = true, sessionId, result },
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
}
// ---- GetRuleResults ------------------------------------------------------------------
[Fact]
public void GetRuleResultsResponse_matches_the_analysis_shape_with_persist_failures()
{
var sessionId = "7c1e2a90-2222-4b7c-a1d2-52f3bbbb0002";
var results = new List<RuleResult>
{
new RuleResult
{
ResultId = "9d0f1a2b-0001-4e0e-8888-52f3bbbb0003",
SessionId = sessionId,
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
RuleId = "ANALYZE-NET-001",
RuleTitle = "Network timeout during ESP",
Severity = "critical",
Category = "network",
ConfidenceScore = 90,
Explanation = "sample",
DetectedAt = new DateTime(2026, 8, 30, 11, 0, 0, DateTimeKind.Utc),
},
new RuleResult
{
ResultId = "9d0f1a2b-0002-4e0e-8888-52f3bbbb0004",
SessionId = sessionId,
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
RuleId = "ANALYZE-APP-017",
RuleTitle = "Resolved finding",
Severity = "warning",
Category = "apps",
ConfidenceScore = 70,
Explanation = "sample",
DetectedAt = new DateTime(2026, 8, 30, 11, 5, 0, DateTimeKind.Utc),
ResolvedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
},
};
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
var persistFailureRuleIds = new List<string> { "ANALYZE-NET-001" };
AssertParity(
new
{
success = persistFailureRuleIds.Count == 0,
sessionId,
results,
totalIssues = openResults.Count,
criticalCount = openResults.Count(r => r.Severity == "critical"),
highCount = openResults.Count(r => r.Severity == "high"),
warningCount = openResults.Count(r => r.Severity == "warning"),
persistFailureCount = persistFailureRuleIds.Count,
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
},
new GetRuleResultsResponse
{
Success = persistFailureRuleIds.Count == 0,
SessionId = sessionId,
Results = results,
TotalIssues = openResults.Count,
CriticalCount = openResults.Count(r => r.Severity == "critical"),
HighCount = openResults.Count(r => r.Severity == "high"),
WarningCount = openResults.Count(r => r.Severity == "warning"),
PersistFailureCount = persistFailureRuleIds.Count,
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
});
}
[Fact]
public void GetRuleResultsResponse_omits_persistFailureRuleIds_on_the_happy_path()
{
var sessionId = "7c1e2a90-3333-4b7c-a1d2-52f3bbbb0005";
var results = new List<RuleResult>();
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
var persistFailureRuleIds = new List<string>();
AssertParity(
new
{
success = persistFailureRuleIds.Count == 0,
sessionId,
results,
totalIssues = openResults.Count,
criticalCount = openResults.Count(r => r.Severity == "critical"),
highCount = openResults.Count(r => r.Severity == "high"),
warningCount = openResults.Count(r => r.Severity == "warning"),
persistFailureCount = persistFailureRuleIds.Count,
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
},
new GetRuleResultsResponse
{
Success = true,
SessionId = sessionId,
Results = results,
TotalIssues = 0,
CriticalCount = 0,
HighCount = 0,
WarningCount = 0,
PersistFailureCount = 0,
PersistFailureRuleIds = null,
});
}
// ---- ReseedImeLogPatterns ------------------------------------------------------------
[Fact]
public void ReseedImeLogPatternsResponse_matches_the_reseed_summary_shape()
{
var deleted = 79;
var written = 81;
AssertParity(
new
{
success = true,
message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
deleted,
written
},
new ReseedImeLogPatternsResponse
{
Success = true,
Message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
Deleted = deleted,
Written = written
});
}
// ---- TestLogPattern ------------------------------------------------------------------
[Fact]
public void TestLogPatternResponse_matches_the_pattern_test_shape()
{
var isTextMode = false;
var result = new LogPatternTestResult
{
MatchCount = 1,
ParseFailureCount = 0,
TimeoutCount = 0,
};
result.Lines.Add(new LogPatternLineResult
{
LineNumber = 1,
Outcome = "matched",
Groups = new Dictionary<string, string> { ["level"] = "ERROR", ["message"] = "disk full" },
MatchedText = "ERROR: disk full",
Component = "Installer",
CmTraceType = 3,
Message = "ERROR: disk full while unpacking",
});
// no_match row: every nullable slot stays null → the keys must vanish on both sides.
result.Lines.Add(new LogPatternLineResult
{
LineNumber = 2,
Outcome = "no_match",
});
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
AssertParity(
new { success = true, format = isTextMode ? "text" : "cmtrace", result },
new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
}
// ---- GetPreviewWhitelist -------------------------------------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only
// in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row.
[Fact]
public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape()
{
var approved = new List<PreviewWhitelistTenantEntry>
{
new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" },
new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" },
};
var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved });
Assert.Equal(
"{\"tenants\":["
+ "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"},"
+ "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}"
+ "]}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("approvedAt", json);
}
// ---- GetPreviewNotificationEmail -----------------------------------------------------
[Fact]
public void GetPreviewNotificationEmailResponse_matches_the_coalesced_email_shape()
{
string? email = "ops@fabrikam.com";
AssertParity(
new { email = email ?? "" },
new GetPreviewNotificationEmailResponse { Email = email ?? "" });
// No stored address: the site coalesces to "" — the key stays present.
email = null;
AssertParity(
new { email = email ?? "" },
new GetPreviewNotificationEmailResponse { Email = "" });
}
// ---- GetAllPreviewNotificationEmails -------------------------------------------------
[Fact]
public void GetAllPreviewNotificationEmailsResponse_matches_the_address_map_shape()
{
var emails = new Dictionary<string, string>
{
["6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d"] = "ops@contoso.com",
["0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01"] = "it@fabrikam.com",
};
AssertParity(
new { count = emails.Count, emails },
new GetAllPreviewNotificationEmailsResponse { Count = emails.Count, Emails = emails });
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -231,7 +231,10 @@ public class SessionAnnotationFunctionsTests
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
// Production wire settings (camelCase, absent-when-null, string enums) — assertions
// must run against the shape the deployed worker actually serializes.
services.Configure<WorkerOptions>(o => o.Serializer =
new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
@@ -397,11 +397,7 @@ public class SessionDeletionMaintenanceFunctionTests
}
return Task.CompletedTask;
});
var alertDispatch = new OpsAlertDispatchService(
AdminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var maintRepo = new Mock<IMaintenanceRepository>();
@@ -659,11 +659,7 @@ public class SessionDeletionPr4cCodexFixesTests
// backed by a noop repository — F5 tests assert CAS ordering, not the OpsEvent itself.
var opsRepoStub = new Mock<IOpsEventRepository>();
opsRepoStub.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var alertDispatchStub = new OpsAlertDispatchService(
AdminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatchStub = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsServiceStub = new OpsEventService(opsRepoStub.Object, NullLogger<OpsEventService>.Instance, alertDispatchStub);
// PR-B Codex F4 follow-up: worker now reads DeletionProgress on the poison path.
@@ -679,11 +679,7 @@ public class SessionDeletionWorkerTests
lock (OpsEventCalls) OpsEventCalls.Add(CapturedOpsEvent.From(e));
return Task.CompletedTask;
});
var alertDispatch = new OpsAlertDispatchService(
AdminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new SessionDeletionWorker(
@@ -46,12 +46,7 @@ public class SessionOwnerBindingObserverTests
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new SessionOwnerBindingObserver(
@@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -96,10 +97,12 @@ public class SessionScopeResolutionTests
}
[Fact]
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin_WhenTheSessionRowCorroboratesTheLookup()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant });
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
@@ -107,6 +110,38 @@ public class SessionScopeResolutionTests
Assert.Equal(OtherTenant, result);
}
[Fact]
public async Task RequireGlobalAdmin_KeepsTargetTenant_WhenTheLookupPointsAtATenantWithoutTheSessionRow()
{
// A lookup row whose tenant does not hold the Sessions row (poisoned or stale mapping)
// must never steer a GA WRITE into that tenant — silently keep the target tenant so the
// endpoint's own not-found handling answers; no operator confirmation dialog.
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId)).ReturnsAsync((SessionSummary?)null);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
Assert.Equal(HomeTenant, result);
}
[Fact]
public async Task ReadPath_DoesNotCorroborate_TheLookupWithASessionRead()
{
// Reads keep the single point-read contract: the endpoint's own session read is the
// corroboration (404 when the row is missing in the resolved tenant).
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(OtherTenant, result);
repo.VerifyAll();
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
{
@@ -151,14 +186,31 @@ public class SessionScopeResolutionTests
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
// Self-heal: the lookup row is written so the next resolve is a point-read.
harness.Lookup.Verify(t => t.UpsertEntityAsync(
// Self-heal: the lookup row is written ADD-ONLY so the next resolve is a point-read and
// a claim that landed meanwhile is never overwritten.
harness.Lookup.Verify(t => t.AddEntityAsync(
It.Is<TableEntity>(e => e.PartitionKey == SessionId
&& e.RowKey == "tenant"
&& e.GetString("TenantId") == OtherTenant),
It.IsAny<TableUpdateMode>(),
It.IsAny<CancellationToken>()),
Times.Once);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task SelfHeal_SwallowsAConcurrentClaim_AndStillReturnsTheScannedTenant()
{
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
harness.Lookup
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
}
[Fact]
@@ -169,8 +221,8 @@ public class SessionScopeResolutionTests
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Null(result);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
harness.Lookup.Verify(t => t.AddEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()),
Times.Never);
}
@@ -246,7 +298,7 @@ public class SessionScopeResolutionTests
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
}
Lookup
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
@@ -0,0 +1,983 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Sessions function folder (anonymous-object → typed-DTO
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
/// call site (copied from the pre-migration code, filled with realistic sample values)
/// against the NEW DTO carrying the same values, via
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
/// get a null case proving the key vanishes identically on both sides.
/// </summary>
public class SessionsWireParityTests
{
private static SessionSummary SampleSummary(string sessionId) => new SessionSummary
{
SessionId = sessionId,
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SerialNumber = "SN-0042",
DeviceName = "DESKTOP-CONTOSO1",
Manufacturer = "Fabrikam Inc.",
Model = "Latitude 9999",
StartedAt = new DateTime(2026, 8, 30, 11, 15, 0, DateTimeKind.Utc),
CurrentPhase = 3,
CurrentPhaseDetail = "Device Setup",
Status = SessionStatus.InProgress,
FailureReason = string.Empty,
};
// ---- GetSessions / GetAllSessions / SearchSessionsByCve / SearchSessionsByEvent ------
[Fact]
public void SessionListResponse_matches_the_paged_listing_shape()
{
IReadOnlyList<SessionSummary> items = new List<SessionSummary>
{
SampleSummary("0b6f7a37-1111-4d61-9c93-0aa111111111"),
SampleSummary("0b6f7a37-2222-4d61-9c93-0aa222222222"),
};
string? nextLink = "/api/sessions?pageSize=2&continuation=abc";
AssertParity(
new
{
success = true,
count = items.Count,
sessions = items,
nextLink,
},
new SessionListResponse
{
Success = true,
Count = items.Count,
Sessions = items,
NextLink = nextLink,
});
}
[Fact]
public void SessionListResponse_omits_nextLink_on_the_last_page()
{
IReadOnlyList<SessionSummary> items = new List<SessionSummary>();
string? nextLink = null;
AssertParity(
new
{
success = true,
count = items.Count,
sessions = items,
nextLink,
},
new SessionListResponse
{
Success = true,
Count = items.Count,
Sessions = items,
NextLink = null,
});
}
// ---- SearchSessions ------------------------------------------------------------------
[Fact]
public void SearchSessionsResponse_matches_the_unprojected_shape()
{
IReadOnlyList<SessionSummary> pageItems = new List<SessionSummary>
{
SampleSummary("3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001"),
};
// Old site: `object sessionsPayload` — either the full items or the fields= projection.
object sessionsPayload = pageItems;
string? nextLink = "/api/search/sessions?pageSize=1&continuation=xyz";
AssertParity(
new
{
success = true,
count = pageItems.Count,
sessions = sessionsPayload,
nextLink,
},
new SearchSessionsResponse
{
Success = true,
Count = pageItems.Count,
Sessions = pageItems,
NextLink = nextLink,
});
}
[Fact]
public void SearchSessionsResponse_matches_the_fields_projected_shape_and_omits_null_nextLink()
{
var projected = new List<Dictionary<string, object?>>
{
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["sessionId"] = "3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
["status"] = "Failed",
["startedAt"] = new DateTime(2026, 8, 29, 7, 0, 0, DateTimeKind.Utc),
},
};
object sessionsPayload = projected;
string? nextLink = null;
AssertParity(
new
{
success = true,
count = 1,
sessions = sessionsPayload,
nextLink,
},
new SearchSessionsResponse
{
Success = true,
Count = 1,
Sessions = projected,
NextLink = null,
});
}
// ---- GetSessionStats / GetAllSessionStats --------------------------------------------
[Fact]
public void SessionStatsResponse_matches_the_stats_shape()
{
var stats = new SessionStats
{
Days = 7,
ActiveCount = 3,
TotalLastNDays = 42,
SucceededLastNDays = 30,
FailedLastNDays = 5,
IncompleteLastNDays = 7,
SuccessRatePct = 86,
AvgDurationMinutes = 55,
MedianDurationMinutes = 48,
P90DurationMinutes = 92,
TotalToday = 4,
FailedToday = 1,
ComputedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
};
AssertParity(
new { success = true, stats },
new SessionStatsResponse { Success = true, Stats = stats });
}
// ---- GetSession ----------------------------------------------------------------------
[Fact]
public void GetSessionResponse_matches_the_single_session_shape()
{
var session = SampleSummary("9d2f5f70-cccc-4f31-9f1e-52f3aaaa0003");
AssertParity(
new
{
success = true,
session
},
new GetSessionResponse
{
Success = true,
Session = session
});
}
// ---- GetSessionEvents (unpaginated + paginated) --------------------------------------
[Fact]
public void GetSessionEventsResponse_matches_the_unpaginated_shape_without_a_nextLink_key()
{
var sessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004";
// EventFieldProjection.Project returns List<object> (full events or dictionary projections).
var events = new List<object>
{
new EnrollmentEvent
{
EventId = "evt-0001",
SessionId = sessionId,
EventType = "phase_transition",
Timestamp = new DateTime(2026, 8, 30, 11, 20, 0, DateTimeKind.Utc),
},
new Dictionary<string, object?> { ["eventType"] = "app_install_start" },
};
AssertParity(
new
{
success = true,
sessionId,
count = events.Count,
events,
},
new GetSessionEventsResponse
{
Success = true,
SessionId = sessionId,
Count = events.Count,
Events = events,
// NextLink stays null → the key is absent, exactly like the old literal
// that never declared it.
});
}
[Fact]
public void GetSessionEventsResponse_matches_the_paginated_shape_with_a_nextLink()
{
var sessionId = "5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005";
var events = new List<object>();
string? nextLink = "/api/sessions/5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005/events?pageSize=100&continuation=tok";
AssertParity(
new
{
success = true,
sessionId,
count = events.Count,
events,
nextLink,
},
new GetSessionEventsResponse
{
Success = true,
SessionId = sessionId,
Count = events.Count,
Events = events,
NextLink = nextLink,
});
}
[Fact]
public void GetSessionEventsResponse_omits_a_null_nextLink_on_the_paginated_path()
{
var sessionId = "5c8e1b20-ffff-49a2-8bd1-52f3aaaa0006";
var events = new List<object>();
string? nextLink = null;
AssertParity(
new
{
success = true,
sessionId,
count = events.Count,
events,
nextLink,
},
new GetSessionEventsResponse
{
Success = true,
SessionId = sessionId,
Count = events.Count,
Events = events,
NextLink = null,
});
}
// ---- GetSessionSignals ---------------------------------------------------------------
[Fact]
public void GetSessionSignalsResponse_matches_the_signal_log_shape()
{
var sessionId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
var signals = new List<SignalRecord>
{
new SignalRecord
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SessionId = sessionId,
SessionSignalOrdinal = 1,
SessionTraceOrdinal = 5,
Kind = "EspPhaseObserved",
KindSchemaVersion = 1,
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 25, 0, DateTimeKind.Utc),
SourceOrigin = "agent",
PayloadJson = "{\"phase\":\"DeviceSetup\"}",
},
};
AssertParity(
new
{
success = true,
sessionId,
count = signals.Count,
truncated = false,
signals,
},
new GetSessionSignalsResponse
{
Success = true,
SessionId = sessionId,
Count = signals.Count,
Truncated = false,
Signals = signals,
});
}
// ---- GetSessionReducerVerification ---------------------------------------------------
[Fact]
public void GetSessionReducerVerificationResponse_matches_the_report_shape()
{
var report = new ReducerVerificationReport
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SessionId = "8bb31d22-0002-4b7c-a1d2-52f3aaaa0008",
SignalCount = 12,
TransitionCount = 11,
StoredReducerVersion = "r-2026.08",
CurrentReducerVersion = "r-2026.08",
SignalOrdinalsContiguous = true,
SignalOrdinalFirst = 1,
SignalOrdinalLast = 12,
StepIndicesContiguous = true,
StepIndexFirst = 0,
StepIndexLast = 10,
SemanticReplayPerformed = true,
SemanticReplayFinalStageMatches = true,
ReplayedFinalStage = "Completed",
Issues = new List<VerificationIssue>
{
new VerificationIssue { Severity = "Info", Kind = "empty_session", Message = "sample" },
},
};
AssertParity(
new
{
success = true,
truncated = false,
report,
},
new GetSessionReducerVerificationResponse
{
Success = true,
Truncated = false,
Report = report,
});
}
// ---- GetSessionDecisionGraph ---------------------------------------------------------
[Fact]
public void GetSessionDecisionGraphResponse_matches_the_graph_shape()
{
var projection = new DecisionGraphProjection
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
SessionId = "9cc42e33-0003-4b7c-a1d2-52f3aaaa0009",
Nodes = new List<DecisionGraphNode>
{
new DecisionGraphNode { Id = "EspInProgress", IsTerminal = false, VisitCount = 1 },
},
Edges = new List<DecisionGraphEdge>
{
new DecisionGraphEdge
{
StepIndex = 0,
FromStage = "Registered",
ToStage = "EspInProgress",
Trigger = "EspPhaseObserved",
Taken = true,
SignalOrdinalRef = 1,
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 26, 0, DateTimeKind.Utc),
},
},
ReducerVersion = "r-2026.08",
};
AssertParity(
new
{
success = true,
truncated = false,
graph = projection,
},
new GetSessionDecisionGraphResponse
{
Success = true,
Truncated = false,
Graph = projection,
});
}
// ---- GetSessionDeletionsList ---------------------------------------------------------
[Fact]
public void GetSessionDeletionsListResponse_matches_the_deletion_listing_shape()
{
var state = "Queued";
int? strandedSinceMinutes = 30;
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var sessionId = "add53f44-0004-4b7c-a1d2-52f3aaaa0010";
var deletionState = "Queued";
var manifestId = "m-20260830-01";
var timestamp = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc).ToString("o");
var ageMinutes = 65;
var anonymousSessions = new List<object>
{
new
{
tenantId,
sessionId,
deletionState,
manifestId,
timestamp,
ageMinutes,
},
};
var typedSessions = new List<SessionDeletionListItem>
{
new SessionDeletionListItem
{
TenantId = tenantId,
SessionId = sessionId,
DeletionState = deletionState,
ManifestId = manifestId,
Timestamp = timestamp,
AgeMinutes = ageMinutes,
},
};
AssertParity(
new
{
success = true,
state,
strandedSinceMinutes,
count = anonymousSessions.Count,
sessions = anonymousSessions,
},
new GetSessionDeletionsListResponse
{
Success = true,
State = state,
StrandedSinceMinutes = strandedSinceMinutes,
Count = typedSessions.Count,
Sessions = typedSessions,
});
}
[Fact]
public void GetSessionDeletionsListResponse_omits_a_null_strandedSinceMinutes()
{
var state = "Poisoned";
int? strandedSinceMinutes = null;
var sessions = new List<object>();
AssertParity(
new
{
success = true,
state,
strandedSinceMinutes,
count = sessions.Count,
sessions,
},
new GetSessionDeletionsListResponse
{
Success = true,
State = state,
StrandedSinceMinutes = null,
Count = 0,
Sessions = new List<SessionDeletionListItem>(),
});
}
// ---- GetTenantDeletionManifests ------------------------------------------------------
[Fact]
public void GetTenantDeletionManifestsResponse_matches_the_manifest_tree_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
string? sessionFilter = "bee64055-0005-4b7c-a1d2-52f3aaaa0011";
var manifestId = "m-20260829-02";
long sizeBytes = 20480;
var lastModifiedUtc = new DateTime(2026, 8, 29, 18, 30, 0, DateTimeKind.Utc).ToString("o");
var anonymousManifests = new List<object>
{
new
{
manifestId,
sizeBytes,
lastModifiedUtc,
},
};
var anonymousSessions = new List<object>
{
new
{
sessionId = sessionFilter,
manifestCount = anonymousManifests.Count,
latestManifestUtc = lastModifiedUtc,
manifests = anonymousManifests,
},
};
var typedManifests = new List<TenantDeletionManifestItem>
{
new TenantDeletionManifestItem
{
ManifestId = manifestId,
SizeBytes = sizeBytes,
LastModifiedUtc = lastModifiedUtc,
},
};
var typedSessions = new List<TenantDeletionManifestSessionNode>
{
new TenantDeletionManifestSessionNode
{
SessionId = sessionFilter!,
ManifestCount = typedManifests.Count,
LatestManifestUtc = lastModifiedUtc,
Manifests = typedManifests,
},
};
AssertParity(
new
{
success = true,
tenantId,
sessionFilter,
sessionCount = anonymousSessions.Count,
manifestCount = 1,
sessions = anonymousSessions,
},
new GetTenantDeletionManifestsResponse
{
Success = true,
TenantId = tenantId,
SessionFilter = sessionFilter,
SessionCount = typedSessions.Count,
ManifestCount = 1,
Sessions = typedSessions,
});
}
[Fact]
public void GetTenantDeletionManifestsResponse_omits_a_null_sessionFilter()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
string? sessionFilter = null;
var sessions = new List<object>();
AssertParity(
new
{
success = true,
tenantId,
sessionFilter,
sessionCount = sessions.Count,
manifestCount = 0,
sessions,
},
new GetTenantDeletionManifestsResponse
{
Success = true,
TenantId = tenantId,
SessionFilter = null,
SessionCount = 0,
ManifestCount = 0,
Sessions = new List<TenantDeletionManifestSessionNode>(),
});
}
// ---- GetTenantsWithDeletionManifests -------------------------------------------------
[Fact]
public void GetTenantsWithDeletionManifestsResponse_matches_the_tenant_id_listing_shape()
{
var sorted = new List<string>
{
"0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
};
AssertParity(
new
{
success = true,
count = sorted.Count,
tenantIds = sorted,
},
new GetTenantsWithDeletionManifestsResponse
{
Success = true,
Count = sorted.Count,
TenantIds = sorted,
});
}
// ---- QuickSearchSessions -------------------------------------------------------------
[Fact]
public void QuickSearchSessionsResponse_matches_the_typeahead_shape()
{
var results = new List<QuickSearchResult>
{
new QuickSearchResult
{
SessionId = "cff75166-0006-4b7c-a1d2-52f3aaaa0012",
SerialNumber = "SN-0042",
DeviceName = "DESKTOP-CONTOSO1",
Status = SessionStatus.Succeeded,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
MatchedField = "serialNumber",
},
};
AssertParity(
new
{
success = true,
count = results.Count,
results
},
new QuickSearchSessionsResponse
{
Success = true,
Count = results.Count,
Results = results
});
}
// ---- GetSessionDeletePreview (mode=summary) ------------------------------------------
[Fact]
public void GetSessionDeletePreviewResponse_matches_the_summary_shape()
{
string? inFlightHint = "Cascade in progress (state=Running, manifestId=m-1); preview shows current data anyway.";
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1, ["Events"] = 250 };
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
{
["Events"] = new List<object> { new { pk = "tenant_session", rk = "0000000000000000001" } },
};
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
{
["Events"] = new List<DeletionRowKeySample>
{
new DeletionRowKeySample { Pk = "tenant_session", Rk = "0000000000000000001" },
},
};
long totalRowCount = 251;
long estimatedBytes = 40960;
long builderDurationMs = 320;
var schemaHash = "sha256:abcd";
var manifestId = "m-20260830-03";
AssertParity(
new
{
success = true,
mode = "summary",
inFlightHint,
preflightCounts,
sampleKeys = anonymousSampleKeys,
estimatedRowCount = totalRowCount,
estimatedSnapshotBytes = estimatedBytes,
builderDurationMs,
schemaHash,
manifestId,
},
new GetSessionDeletePreviewResponse
{
Success = true,
Mode = "summary",
InFlightHint = inFlightHint,
PreflightCounts = preflightCounts,
SampleKeys = typedSampleKeys,
EstimatedRowCount = totalRowCount,
EstimatedSnapshotBytes = estimatedBytes,
BuilderDurationMs = builderDurationMs,
SchemaHash = schemaHash,
ManifestId = manifestId,
});
}
[Fact]
public void GetSessionDeletePreviewResponse_omits_a_null_inFlightHint()
{
string? inFlightHint = null;
var preflightCounts = new Dictionary<string, int>();
var sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal);
AssertParity(
new
{
success = true,
mode = "summary",
inFlightHint,
preflightCounts,
sampleKeys,
estimatedRowCount = 0L,
estimatedSnapshotBytes = -1L,
builderDurationMs = 12L,
schemaHash = "sha256:abcd",
manifestId = "m-20260830-04",
},
new GetSessionDeletePreviewResponse
{
Success = true,
Mode = "summary",
InFlightHint = null,
PreflightCounts = preflightCounts,
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
EstimatedRowCount = 0,
EstimatedSnapshotBytes = -1,
BuilderDurationMs = 12,
SchemaHash = "sha256:abcd",
ManifestId = "m-20260830-04",
});
}
// ---- GetSessionDeletionManifest (mode=summary) ---------------------------------------
[Fact]
public void GetSessionDeletionManifestResponse_matches_the_stored_summary_shape_with_progress()
{
var manifestId = "m-20260830-05";
var schemaHash = "sha256:ef01";
var snapshotSha = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1 };
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
{
["Sessions"] = new List<object> { new { pk = "6a6a35a2", rk = "sess-1" } },
};
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
{
["Sessions"] = new List<DeletionRowKeySample> { new DeletionRowKeySample { Pk = "6a6a35a2", Rk = "sess-1" } },
};
var completedSteps = new HashSet<int> { 0, 1, 2 };
DateTime? completedAt = new DateTime(2026, 8, 30, 12, 5, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
mode = "summary",
source = "stored",
manifestId,
schemaHash,
snapshotSha256 = snapshotSha,
estimatedRowCount = 1L,
estimatedSnapshotBytes = 2048L,
preflightCounts,
sampleKeys = anonymousSampleKeys,
progress = new
{
SnapshotSha256 = snapshotSha,
completedStepOrders = completedSteps,
VerificationDone = true,
TombstoneStarted = true,
CompletedAt = completedAt,
aggregateDecrementsApplied = 2,
restoreReIncrementsApplied = 0,
LastFailureType = (string?)"ResidualRows",
LastFailureMessage = (string?)"3 residual rows observed",
LastObservedResidualCount = (int?)3,
LastResidualSampleJson = (string?)"[{\"pk\":\"a\",\"rk\":\"b\"}]",
},
},
new GetSessionDeletionManifestResponse
{
Success = true,
Mode = "summary",
Source = "stored",
ManifestId = manifestId,
SchemaHash = schemaHash,
SnapshotSha256 = snapshotSha,
EstimatedRowCount = 1,
EstimatedSnapshotBytes = 2048,
PreflightCounts = preflightCounts,
SampleKeys = typedSampleKeys,
Progress = new SessionDeletionProgressWire
{
SnapshotSha256 = snapshotSha,
CompletedStepOrders = completedSteps,
VerificationDone = true,
TombstoneStarted = true,
CompletedAt = completedAt,
AggregateDecrementsApplied = 2,
RestoreReIncrementsApplied = 0,
LastFailureType = "ResidualRows",
LastFailureMessage = "3 residual rows observed",
LastObservedResidualCount = 3,
LastResidualSampleJson = "[{\"pk\":\"a\",\"rk\":\"b\"}]",
},
});
}
[Fact]
public void GetSessionDeletionManifestResponse_omits_null_slots_inside_progress()
{
var snapshotSha = "aa86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
var completedSteps = new HashSet<int> { 0 };
AssertParity(
new
{
success = true,
mode = "summary",
source = "stored",
manifestId = "m-20260830-06",
schemaHash = "sha256:ef02",
snapshotSha256 = snapshotSha,
estimatedRowCount = 0L,
estimatedSnapshotBytes = 512L,
preflightCounts = new Dictionary<string, int>(),
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
progress = new
{
SnapshotSha256 = snapshotSha,
completedStepOrders = completedSteps,
VerificationDone = false,
TombstoneStarted = false,
CompletedAt = (DateTime?)null,
aggregateDecrementsApplied = 0,
restoreReIncrementsApplied = 0,
LastFailureType = (string?)null,
LastFailureMessage = (string?)null,
LastObservedResidualCount = (int?)null,
LastResidualSampleJson = (string?)null,
},
},
new GetSessionDeletionManifestResponse
{
Success = true,
Mode = "summary",
Source = "stored",
ManifestId = "m-20260830-06",
SchemaHash = "sha256:ef02",
SnapshotSha256 = snapshotSha,
EstimatedRowCount = 0,
EstimatedSnapshotBytes = 512,
PreflightCounts = new Dictionary<string, int>(),
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
Progress = new SessionDeletionProgressWire
{
SnapshotSha256 = snapshotSha,
CompletedStepOrders = completedSteps,
VerificationDone = false,
TombstoneStarted = false,
CompletedAt = null,
AggregateDecrementsApplied = 0,
RestoreReIncrementsApplied = 0,
LastFailureType = null,
LastFailureMessage = null,
LastObservedResidualCount = null,
LastResidualSampleJson = null,
},
});
}
[Fact]
public void GetSessionDeletionManifestResponse_omits_a_null_progress_object()
{
var snapshotSha = "bb86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
// Old site: `progress = progress == null ? null : new { ... }` — the whole key vanishes.
object? progress = null;
AssertParity(
new
{
success = true,
mode = "summary",
source = "stored",
manifestId = "m-20260830-07",
schemaHash = "sha256:ef03",
snapshotSha256 = snapshotSha,
estimatedRowCount = 0L,
estimatedSnapshotBytes = 256L,
preflightCounts = new Dictionary<string, int>(),
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
progress,
},
new GetSessionDeletionManifestResponse
{
Success = true,
Mode = "summary",
Source = "stored",
ManifestId = "m-20260830-07",
SchemaHash = "sha256:ef03",
SnapshotSha256 = snapshotSha,
EstimatedRowCount = 0,
EstimatedSnapshotBytes = 256,
PreflightCounts = new Dictionary<string, int>(),
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
Progress = null,
});
}
// ---- MarkSessionFailed / MarkSessionSucceeded ----------------------------------------
[Fact]
public void MarkSessionFailed_success_body_matches_SuccessMessageResponse()
{
var sessionId = "dee86277-0007-4b7c-a1d2-52f3aaaa0013";
AssertParity(
new
{
success = true,
message = $"Session {sessionId} marked as failed"
},
new SuccessMessageResponse
{
Success = true,
Message = $"Session {sessionId} marked as failed"
});
}
[Fact]
public void MarkSessionSucceeded_success_body_matches_SuccessMessageResponse()
{
var sessionId = "eff97388-0008-4b7c-a1d2-52f3aaaa0014";
AssertParity(
new
{
success = true,
message = $"Session {sessionId} marked as succeeded"
},
new SuccessMessageResponse
{
Success = true,
Message = $"Session {sessionId} marked as succeeded"
});
}
// ---- QueueSessionAction --------------------------------------------------------------
[Fact]
public void QueueSessionActionResponse_matches_the_queue_acknowledgement_shape()
{
var actionType = "request_diagnostics";
var queuedAt = new DateTime(2026, 8, 30, 13, 37, 0, DateTimeKind.Utc);
AssertParity(
new
{
success = true,
message = $"Action '{actionType}' queued for delivery",
queuedAt
},
new QueueSessionActionResponse
{
Success = true,
Message = $"Action '{actionType}' queued for delivery",
QueuedAt = queuedAt
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -2,6 +2,7 @@ using System.Reflection;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Xunit;
@@ -56,6 +57,9 @@ public sealed class SharedManifestParityTests
["$comment"] = "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. " +
"Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, " +
"then node scripts/generate-shared-manifest-types.js.",
// v2: adds the "types" section (full wire-type graph) and drops v1's
// "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types").
["schemaVersion"] = 2,
["adminConfiguration"] = new Dictionary<string, object?>
{
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
@@ -64,10 +68,6 @@ public sealed class SharedManifestParityTests
{
["fields"] = WireFieldNames(typeof(TenantConfiguration)),
},
["sessionSummary"] = new Dictionary<string, object?>
{
["fields"] = WireFieldsWithOptionality(typeof(SessionSummary)),
},
// Enum member order is load-bearing (append-only ordinals) — declaration order kept.
["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
["enrollmentPhases"] = EnumMap<EnrollmentPhase>(),
@@ -75,6 +75,12 @@ public sealed class SharedManifestParityTests
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
["annotationLanes"] = AnnotationLanes.All,
["annotationVerdicts"] = AnnotationVerdicts.All,
// Ops vocabularies. Declaration order kept for severities — it IS the ladder
// (Info < Warning < Error < Critical), and the MCP renders it in that order.
["opsEventCategories"] = ConstStrings(typeof(OpsEventCategory)),
["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)),
// Declaration order (grouped by category) — the MCP renders this catalog verbatim.
["opsEventTypes"] = OpsEventTypes.All,
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
@@ -84,6 +90,9 @@ public sealed class SharedManifestParityTests
// Declaration order kept: the web derives a union type from this list, order is cosmetic
// but a stable order keeps the generated file diff-minimal.
["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)),
// Every IApiResponse implementer + [WireContract] type, transitively closed —
// the source of utils/wire-types.generated.ts. See WireTypeManifestBuilder.
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
};
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
@@ -99,33 +108,10 @@ public sealed class SharedManifestParityTests
private static string[] WireFieldNames(Type type)
=> WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
/// <summary>
/// Wire names plus whether the field can be ABSENT from the JSON: nullable fields are
/// omitted under WhenWritingNull, so nullable ⇒ the TS mirror must type it optional.
/// </summary>
private static object[] WireFieldsWithOptionality(Type type)
{
var nullability = new NullabilityInfoContext();
return WireProperties(type)
.Select(p => (object)new Dictionary<string, object?>
{
["name"] = JsonNamingPolicy.CamelCase.ConvertName(p.Name),
["optional"] = IsNullable(p, nullability),
})
.ToArray();
}
private static IEnumerable<PropertyInfo> WireProperties(Type type)
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
private static bool IsNullable(PropertyInfo p, NullabilityInfoContext ctx)
{
if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true;
if (p.PropertyType.IsValueType) return false;
return ctx.Create(p).ReadState != NullabilityState.NotNull;
}
private static Dictionary<string, int> EnumMap<TEnum>() where TEnum : struct, Enum
=> Enum.GetValues<TEnum>()
.ToDictionary(v => v.ToString(), v => Convert.ToInt32(v));
@@ -65,7 +65,15 @@ public class SlaMetricsServiceTests
int? durationSeconds = null,
DateTime? startedAt = null)
{
var started = startedAt ?? DateTime.UtcNow.AddHours(-1);
// The CurrentWeek asserts require the session to fall into the CURRENT ISO week
// (SlaMetricsService groups by GetIsoWeekKey, weeks start Monday 00:00 UTC).
// A plain "now - 1h" crosses into LAST week during the first hour of every
// Monday (UTC) and empties CurrentWeek — exactly the CI failure on
// Mon 2026-08-31 00:20Z. Clamp the anchor to the week's Monday instead.
var now = DateTime.UtcNow;
var isoWeekMonday = now.Date.AddDays(-(((int)now.DayOfWeek + 6) % 7));
var anHourAgo = now.AddHours(-1);
var started = startedAt ?? (anHourAgo >= isoWeekMonday ? anHourAgo : isoWeekMonday);
return new SessionSummary
{
SessionId = Guid.NewGuid().ToString(),
@@ -168,11 +168,8 @@ public class SlaTenantStatusLifecycleTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(
_adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook,
NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var telemetry = new TelemetryClient(new TelemetryConfiguration());
@@ -183,7 +180,7 @@ public class SlaTenantStatusLifecycleTests
_maintenanceRepo.Object,
_sessionRepo.Object,
_metricsRepo.Object,
webhook,
channelDispatcher,
NotificationService.Object,
StatusRepo,
_adminConfig.Object,
@@ -419,9 +416,8 @@ public class SlaTenantStatusLifecycleTests
.ReturnsAsync(new RawPage<SessionSummary>(recentPage, null));
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(adminCfg.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook, NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
var opsSvc = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
@@ -432,7 +428,7 @@ public class SlaTenantStatusLifecycleTests
var svc = new SlaBreachEvaluationService(
tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object,
metricsRepo.Object,
webhook, notif.Object, statusRepo, adminCfg.Object, opsSvc,
channelDispatcher, notif.Object, statusRepo, adminCfg.Object, opsSvc,
new TelemetryClient(new TelemetryConfiguration()),
NullLogger<SlaBreachEvaluationService>.Instance);
@@ -850,14 +846,13 @@ public class SlaTenantStatusLifecycleTests
.Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
var alertDispatch = new OpsAlertDispatchService(adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
webhook, NullLogger<OpsAlertDispatchService>.Instance);
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Service = new SlaBreachEvaluationService(
tenantConfigService.Object, _configRepo.Object, _maintenanceRepo.Object, _sessionRepo.Object,
_metricsRepo.Object, webhook, notifService.Object, StatusRepo, adminConfig.Object, opsService,
_metricsRepo.Object, channelDispatcher, notifService.Object, StatusRepo, adminConfig.Object, opsService,
new TelemetryClient(new TelemetryConfiguration()), NullLogger<SlaBreachEvaluationService>.Instance);
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
@@ -386,6 +386,7 @@ public class StoreSessionReregistrationPreserveTests
private sealed class Harness
{
public Mock<TableClient> Sessions { get; }
public Mock<TableClient> Lookup { get; } = new();
public TableStorageService Sut { get; }
public TableEntity? Written { get; private set; }
@@ -423,8 +424,13 @@ public class StoreSessionReregistrationPreserveTests
return Task.FromResult(new Mock<Response>().Object);
});
// Ownership claim (first-writer-wins lookup) — Add succeeds: this tenant owns the id.
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Mock<Response>().Object);
var mockServiceClient = new Mock<TableServiceClient>();
mockServiceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
mockServiceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
// Events / SessionsIndex intentionally unconfigured → null client → SUT swallows.
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// SessionTenantLookup ownership claim in <see cref="TableStorageService.StoreSessionAsync"/>.
/// The lookup (PK = client-supplied sessionId → owning tenantId) is the authority behind every
/// global-scope cross-tenant session resolve. It used to be an unconditional last-writer-wins
/// upsert AFTER the Sessions row, so any tenant's device could re-point a victim sessionId at
/// its own tenant. Now it is a create-only claim BEFORE any session write: a foreign owner
/// refuses the registration (409, nothing written), the same owner re-registers as before.
/// </summary>
public class StoreSessionTenantLookupClaimTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "44444444-4444-4444-4444-444444444444";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private static SessionRegistration Registration(string tenantId) => new()
{
TenantId = tenantId,
SessionId = SessionId,
StartedAt = new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc),
SerialNumber = "SN-1",
};
[Fact]
public async Task FreshSessionId_ClaimsTheLookup_BeforeWritingTheSessionRow()
{
var harness = new Harness(existingOwner: null);
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
Assert.True(ok);
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
Assert.Equal(TenantA, harness.ClaimedTenant);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SameTenantReregistration_KeepsTheExistingClaim_AndStoresNormally()
{
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
Assert.True(ok);
Assert.Contains("session-write", harness.Order);
}
[Fact]
public async Task ForeignTenant_IsRefused_AndNothingIsWritten()
{
// Attacker in tenant B registers the victim's (tenant A) sessionId.
var harness = new Harness(existingOwner: TenantA);
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
Assert.Equal(SessionId, ex.SessionId);
Assert.Equal(TenantB, ex.RequestedTenantId);
Assert.Equal(TenantA, ex.OwningTenantId);
// The mapping still points at A, and B never got a Sessions row for the id.
Assert.DoesNotContain("session-write", harness.Order);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
harness.Lookup.Verify(t => t.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ClaimStorageFailure_IsNotFailSoft_TheSessionIsNotStored()
{
var harness = new Harness(existingOwner: null);
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
Assert.False(ok);
Assert.DoesNotContain("session-write", harness.Order);
}
private sealed class Harness
{
public Mock<TableClient> Sessions { get; } = new();
public Mock<TableClient> Lookup { get; } = new();
public List<string> Order { get; } = new();
public string? ClaimedTenant { get; private set; }
public TableStorageService Sut { get; }
public Harness(string? existingOwner)
{
// Sessions: no existing row; Add captures the write order.
Sessions.Setup(t => t.GetEntityAsync<TableEntity>(
It.IsAny<string>(), SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "ResourceNotFound"));
Sessions.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((_, _) =>
{
Order.Add("session-write");
return Task.FromResult(new Mock<Response>().Object);
});
if (existingOwner is null)
{
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
Order.Add("lookup-add");
ClaimedTenant = e.GetString("TenantId");
return Task.FromResult(new Mock<Response>().Object);
});
}
else
{
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
Lookup.Setup(t => t.GetEntityAsync<TableEntity>(
SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(
new TableEntity(SessionId, "tenant") { ["TenantId"] = existingOwner }, Mock.Of<Response>()));
}
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
// Events / SessionsIndex intentionally unconfigured → null client → SUT swallows.
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}
@@ -283,13 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
memCache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
@@ -0,0 +1,225 @@
using System.Net.Http;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
/// tenant admin from sending through the platform-owned bot.
/// </summary>
public class TelegramChannelProviderTests
{
private static Mock<WebhookNotificationService> WebhookMock()
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
private static Mock<TelegramNotificationService> TelegramMock()
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
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 };
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
// ── Dispatch routing ──────────────────────────────────────────────────
[Fact]
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert);
telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once);
webhook.Verify(w => w.SendNotificationAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert);
webhook.Verify(w => w.SendNotificationAsync(
"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);
}
[Fact]
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
}, alert);
telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once);
webhook.Verify(w => w.SendNotificationAsync(
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task SendToChannels_SkipsChannelsWithoutDestination()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, null, "tg"),
Channel((int)WebhookProviderType.Slack, "", "slack"),
}, new NotificationAlert { Title = "t", Summary = "s" });
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendNotificationAsync(
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()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
var result = await dispatcher.SendWithResultAsync(
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
new NotificationAlert { Title = "t", Summary = "s" });
Assert.True(result.Success);
telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
}
// ── Chat-ID validation ────────────────────────────────────────────────
[Theory]
[InlineData("-1003785642894")]
[InlineData("123456789")]
[InlineData("@salesdesk")]
[InlineData("@Sales_Desk_01")]
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("https://api.telegram.org/bot123/sendMessage")]
[InlineData("-100abc")]
[InlineData("@abc")] // too short
[InlineData("@1sales")] // must start with a letter
[InlineData("@sales-desk")] // hyphen not allowed
[InlineData("123456789012345678901")] // 21 digits
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Fact]
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
{
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
// provider-specific destination branch is wired, not just the format helper.
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
}
[Fact]
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
// ── Global-Admin gate ─────────────────────────────────────────────────
[Fact]
public void TelegramGate_GlobalAdminMayAddChannel()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
[Fact]
public void TelegramGate_TenantAdminMayNotAddChannel()
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
[Fact]
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
{
// The gate must not turn a GA-created Telegram channel into a permanent save block
// for the tenant admin's unrelated configuration changes.
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_IgnoresNonTelegramChannels()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
[Fact]
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
{
var candidate = new TenantConfiguration
{
TenantId = "t1",
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
};
var existing = new TenantConfiguration { TenantId = "t1" };
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
}
}
@@ -224,10 +224,11 @@ public class TenantAdminManagementFunctionTests
{
var services = new ServiceCollection();
services.AddOptions();
// Mirror the production camelCase policy so camelCase test bodies bind to the
// PascalCase request models exactly like real web requests do.
// Production wire settings (camelCase policy binds camelCase test bodies to the
// PascalCase request models exactly like real web requests do; absent-when-null and
// string enums match what the deployed worker serializes).
services.Configure<WorkerOptions>(o => o.Serializer =
new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var requestContext = new RequestContext
@@ -51,13 +51,7 @@ public class TenantApprovalServiceTests
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,

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