Compare commits

...

12 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
364 changed files with 46002 additions and 4339 deletions
+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 }}
+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.
+15
View File
@@ -251,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).
+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.
+28
View File
@@ -1,5 +1,33 @@
# 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).
@@ -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]
@@ -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; }
@@ -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,
@@ -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,
@@ -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 });
}
}
@@ -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(
@@ -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?>()))
@@ -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,
@@ -48,13 +48,7 @@ public class TenantAutoApproveHandlerTests
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);
_approvalMock = new Mock<TenantApprovalService>(
@@ -99,13 +99,7 @@ public class TenantConfigPatchServiceTests
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 TenantConfigPatchService(
@@ -0,0 +1,144 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The TenantTrialStarted ops event — the conversion signal a sales/support channel binds to.
/// Covers the recorded shape (category, severity, payload) and the GA-path emission predicate.
/// <para>
/// The HTTP entry points themselves are not exercised: per this suite's convention the pure
/// cores are tested instead (see PlanManagementTransitionTests), so the GA path is pinned via
/// the changes dictionary that gates the call.
/// </para>
/// </summary>
public class TenantTrialStartedEventTests
{
private static readonly DateTime Now = new(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc);
private const string Caller = "ga@operator.example";
private static (OpsEventService Service, List<OpsEventEntry> Events) NewService()
{
var events = new List<OpsEventEntry>();
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(events.Add)
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
return (new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object)), events);
}
// ── Recorded shape ────────────────────────────────────────────────────
[Fact]
public async Task RecordTenantTrialStarted_WritesTenantInfoEventWithContactPayload()
{
var (service, events) = NewService();
await service.RecordTenantTrialStartedAsync(
"t1", "contoso.example", "admin@contoso.example", Now, Now.AddDays(30), Caller, selfService: true);
var entry = Assert.Single(events);
Assert.Equal(OpsEventCategory.Tenant, entry.Category);
Assert.Equal("TenantTrialStarted", entry.EventType);
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
Assert.Equal("t1", entry.TenantId);
Assert.Equal(Caller, entry.UserId);
Assert.Contains("contoso.example", entry.Message);
using var details = JsonDocument.Parse(entry.Details!);
var root = details.RootElement;
Assert.Equal("contoso.example", root.GetProperty("domainName").GetString());
Assert.Equal("admin@contoso.example", root.GetProperty("contactEmail").GetString());
Assert.Equal(Caller, root.GetProperty("grantedBy").GetString());
Assert.True(root.GetProperty("selfService").GetBoolean());
}
[Fact]
public async Task RecordTenantTrialStarted_DistinguishesOperatorGrantFromSelfService()
{
var (service, events) = NewService();
await service.RecordTenantTrialStartedAsync(
"t1", "contoso.example", null, Now, Now.AddDays(90), Caller, selfService: false);
Assert.Contains("granted by an operator", events.Single().Message);
using var details = JsonDocument.Parse(events.Single().Details!);
Assert.False(details.RootElement.GetProperty("selfService").GetBoolean());
}
[Fact]
public async Task RecordTenantTrialStarted_FallsBackToTenantIdWhenDomainUnknown()
{
var (service, events) = NewService();
await service.RecordTenantTrialStartedAsync("t1", null, null, Now, Now.AddDays(30), Caller, selfService: true);
Assert.Contains("t1", events.Single().Message);
}
// ── GA-path emission predicate ────────────────────────────────────────
// SetPlanTier fires when: changes["TrialExpiresUtc"] was recorded AND a trial end remains.
private static (Dictionary<string, string> Changes, TenantConfiguration Config) Apply(
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
{
var changes = new Dictionary<string, string>();
PlanManagementFunction.ApplyPlanChanges(config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
return (changes, config);
}
private static bool WouldFire((Dictionary<string, string> Changes, TenantConfiguration Config) result)
=> result.Changes.ContainsKey("TrialExpiresUtc") && result.Config.TrialExpiresUtc.HasValue;
[Fact]
public void GaGrantingATrial_Fires()
=> Assert.True(WouldFire(Apply(
new TenantConfiguration { TenantId = "t1", PlanTier = "community" },
trialProvided: true, trialExpiresUtc: Now.AddDays(30))));
[Fact]
public void GaExtendingATrial_Fires()
=> Assert.True(WouldFire(Apply(
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) },
trialProvided: true, trialExpiresUtc: Now.AddDays(60))));
[Fact]
public void GaEndingATrial_DoesNotFire()
{
// Explicit null ends the trial — that is a downgrade, already covered by
// TenantPlanDowngraded. Firing "started" there would be plainly wrong.
Assert.False(WouldFire(Apply(
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) },
trialProvided: true, trialExpiresUtc: null)));
}
[Fact]
public void PlanTierOnlyChange_DoesNotFire()
=> Assert.False(WouldFire(Apply(
new TenantConfiguration { TenantId = "t1", PlanTier = "community" }, planTier: "pro")));
[Fact]
public void ResubmittingTheSameTrialDate_DoesNotFire()
{
// ApplyPlanChanges only records a change when the value actually moved, so an idempotent
// save must not re-announce the conversion.
var expiry = Now.AddDays(30);
Assert.False(WouldFire(Apply(
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = expiry },
trialProvided: true, trialExpiresUtc: expiry)));
}
}
@@ -0,0 +1,30 @@
using System.Net.Http;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> but do not
/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare
/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport
/// resolves its bot URL from a mocked repository that returns nothing.
/// <para>
/// Tests that DO assert on dispatch should mock the transports directly — see
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
/// </para>
/// </summary>
internal static class TestNotifications
{
internal static NotificationChannelDispatcher InertDispatcher()
=> new(
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance));
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
=> new(adminConfigService, InertDispatcher(), NullLogger<OpsAlertDispatchService>.Instance);
}
@@ -0,0 +1,21 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Serializes test payloads with the PRODUCTION wire options (<see cref="ApiJsonOptions"/> —
/// camelCase, WhenWritingNull), the policy every test harness with its own serializer must
/// follow. Shape tests serialized DTOs with default options before the 2026-08-31 completion
/// pass; that only worked while the payloads were anonymous objects with lowercase members.
/// Generic so the CONCRETE type reaches System.Text.Json (a declared object parameter would
/// too, but the generic also keeps compile-time typing at the call site).
/// </summary>
internal static class TestWire
{
private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create();
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
public static JsonElement SerializeToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
}
@@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests
// returned 31 day keys under a "windowDays: 30" label.
var today = new System.DateTime(2026, 7, 27);
Assert.Equal(today.AddDays(-29),
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
}
}
@@ -50,11 +50,7 @@ public class TrialExpirySweepFunctionTests
{ CallBase = false };
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
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 sut = new TrialExpirySweepFunction(
@@ -248,11 +244,7 @@ public class TrialExpirySweepFunctionTests
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
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 sut = new TrialExpirySweepFunction(
configRepo.Object,
@@ -0,0 +1,879 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Backup;
using AutopilotMonitor.Shared.Models.Metrics;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proofs for the 2026-08-31 typed-contract COMPLETION pass: the endpoints the
/// original migration missed because their anonymous literal was smuggled past the ratchet
/// (variable indirection, object-returning builders, Serialize+WriteString, local WriteJson
/// wrappers). Same contract as <see cref="ApiResponseWireParityTests"/>: OLD anonymous
/// literal (copied from the pre-conversion diff) vs NEW DTO, ordinal JSON equality, plus a
/// null-slot case wherever a key must vanish. Sites converted by MOVING an already-named
/// class (PlatformAgentMetricsResponse family, AgentEfficiency family, VerdictCalibration
/// path rows, DeviceJourneyWindowTotals, OffboardResponse) are wire-identical by
/// construction (System.Text.Json ignores namespaces) and carry no fact.
/// </summary>
public class TypedContractCompletionWireParityTests
{
private static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
// ── rule-stats (tenant + global) ─────────────────────────────────────────────────────
[Fact]
public void RuleStatsResponse_matches_the_tenant_shape_without_uniqueRules()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = new[]
{
new
{
ruleId = "ANALYZE-NET-001",
ruleType = "analyze",
ruleTitle = "Network issue",
category = "network",
severity = "warning",
fireCount = 3,
evaluationCount = 10,
sessionsEvaluated = 9,
hitRate = 30.0,
avgConfidenceScore = 71.5,
trend = new[] { new { date = "2026-08-30", fireCount = 3, evaluationCount = 10 } },
},
},
regressions,
summary = new
{
totalEvaluations = 10,
totalFires = 3,
overallHitRate = 30.0,
topRuleByFireCount = (string?)"ANALYZE-NET-001",
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = new[]
{
new RuleStatsRuleAggregate
{
RuleId = "ANALYZE-NET-001",
RuleType = "analyze",
RuleTitle = "Network issue",
Category = "network",
Severity = "warning",
FireCount = 3,
EvaluationCount = 10,
SessionsEvaluated = 9,
HitRate = 30.0,
AvgConfidenceScore = 71.5,
Trend = new[] { new RuleTrendPoint { Date = "2026-08-30", FireCount = 3, EvaluationCount = 10 } },
},
},
Regressions = regressions,
// Tenant route: UniqueRules stays null → the key is absent, matching the
// historical tenant shape which never carried it.
Summary = new RuleStatsSummary
{
TotalEvaluations = 10,
TotalFires = 3,
OverallHitRate = 30.0,
TopRuleByFireCount = "ANALYZE-NET-001",
UniqueRules = null,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
[Fact]
public void RuleStatsResponse_matches_the_global_shape_with_uniqueRules_and_null_topRule()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = Array.Empty<object>(),
regressions,
summary = new
{
totalEvaluations = 0,
totalFires = 0,
overallHitRate = 0.0,
topRuleByFireCount = (string?)null, // empty window → key vanishes
uniqueRules = 0,
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = Array.Empty<RuleStatsRuleAggregate>(),
Regressions = regressions,
Summary = new RuleStatsSummary
{
TotalEvaluations = 0,
TotalFires = 0,
OverallHitRate = 0.0,
TopRuleByFireCount = null,
UniqueRules = 0,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
// ── verdict-calibration ──────────────────────────────────────────────────────────────
[Fact]
public void VerdictCalibrationResponse_matches_the_envelope_incl_computedAt_omission()
{
// Path rows and alerts were ALREADY named classes on the old wire (moved verbatim) —
// the envelope keys are what the conversion changed, so the fact pins those with the
// row lists empty; the no-data window additionally proves computedAt vanishes.
var paths = new List<VerdictCalibrationPathRow>();
var alerts = new List<VerdictCalibrationAlert>();
AssertWireIdentical(
new
{
success = true,
tenantId = "global",
windowDays = 30,
windowStart = "2026-08-02",
windowEnd = "2026-08-31",
computedAt = (DateTime?)null,
versions = new[] { 1, 2 },
totals = new { sessions = 0, terminal = 0, derived = 0, days = 0 },
trend = new { windowDays = 7, baselineDays = 28, windowSessions = 0, baselineSessions = 0 },
paths,
alerts,
},
new VerdictCalibrationResponse
{
Success = true,
TenantId = "global",
WindowDays = 30,
WindowStart = "2026-08-02",
WindowEnd = "2026-08-31",
ComputedAt = null,
Versions = new[] { 1, 2 },
Totals = new VerdictCalibrationTotals { Sessions = 0, Terminal = 0, Derived = 0, Days = 0 },
Trend = new VerdictCalibrationTrendMeta { WindowDays = 7, BaselineDays = 28, WindowSessions = 0, BaselineSessions = 0 },
Paths = paths,
Alerts = alerts,
});
}
// ── metrics/app ──────────────────────────────────────────────────────────────────────
[Fact]
public void AppMetricsResponse_matches_the_anonymous_shape()
{
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 4,
totalSkipped = 1,
totalUnmeasured = 1,
totalCollisionExcluded = 0,
slowestApps = new[]
{
new
{
appName = "Contoso App",
totalInstalls = 4,
succeeded = 2,
skipped = 1,
unmeasured = 1,
failed = 1,
failureRate = 33.3,
avgDurationSeconds = 120.0,
maxDurationSeconds = 240,
measuredInstalls = 1,
avgDownloadBytes = 1048576L,
doTotalBytesDownloaded = 2097152L,
doBytesFromPeers = 1048576L,
doBytesFromCacheServer = 0L,
doBytesFromHttp = 1048576L,
peerOffloadPercent = 50.0,
topFailureCodes = new[] { new { code = "0x80070005", count = 1 } },
},
},
topFailingApps = Array.Empty<object>(),
deliveryOptimization = new
{
totalBytesDownloaded = 2097152L,
fromPeers = 1048576L,
fromCacheServer = 0L,
fromHttp = 1048576L,
peerOffloadPercent = 50.0,
},
},
new AppMetricsResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 4,
TotalSkipped = 1,
TotalUnmeasured = 1,
TotalCollisionExcluded = 0,
SlowestApps = new[]
{
new AppMetricsAppGroup
{
AppName = "Contoso App",
TotalInstalls = 4,
Succeeded = 2,
Skipped = 1,
Unmeasured = 1,
Failed = 1,
FailureRate = 33.3,
AvgDurationSeconds = 120.0,
MaxDurationSeconds = 240,
MeasuredInstalls = 1,
AvgDownloadBytes = 1048576L,
DoTotalBytesDownloaded = 2097152L,
DoBytesFromPeers = 1048576L,
DoBytesFromCacheServer = 0L,
DoBytesFromHttp = 1048576L,
PeerOffloadPercent = 50.0,
TopFailureCodes = new[] { new AppFailureCodeCount { Code = "0x80070005", Count = 1 } },
},
},
TopFailingApps = Array.Empty<AppMetricsAppGroup>(),
DeliveryOptimization = new AppMetricsDeliveryOptimization
{
TotalBytesDownloaded = 2097152L,
FromPeers = 1048576L,
FromCacheServer = 0L,
FromHttp = 1048576L,
PeerOffloadPercent = 50.0,
},
});
}
// ── metrics/ime-versions (non-global projection) ─────────────────────────────────────
[Fact]
public void ImeVersionHistoryLeanEntry_matches_the_anonymous_projection()
{
var seen = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
var expected = JsonSerializer.Serialize(
new { Version = "1.104.102.0", FirstSeenAt = seen, LastSeenAt = seen.AddDays(3), SessionCount = 12 },
ApiJsonOptions.Create());
var actual = JsonSerializer.Serialize(
new ImeVersionHistoryLeanEntry
{
Version = "1.104.102.0",
FirstSeenAt = seen,
LastSeenAt = seen.AddDays(3),
SessionCount = 12,
},
ApiJsonOptions.Create());
Assert.Equal(expected, actual);
}
// ── metrics/time-attribution (fleet) ─────────────────────────────────────────────────
[Fact]
public void TimeAttributionMetricsResponse_matches_the_envelope()
{
// Row lists are TimeAttributionDailyAggregate on BOTH sides (identical class) —
// the envelope keys are the conversion.
var rows = new List<TimeAttributionDailyAggregate>();
AssertWireIdentical(
new { success = true, windowDays = 30, classes = rows, daily = rows },
new TimeAttributionMetricsResponse { Success = true, WindowDays = 30, Classes = rows, Daily = rows });
}
// ── metrics/device-journeys ──────────────────────────────────────────────────────────
[Fact]
public void DeviceJourneyMetricsResponse_matches_the_envelope_incl_repeatDevices_omission()
{
var totals = new DeviceJourneyWindowTotals { CompletedJourneys = 0, FirstTimeRight = 0, FtrRatePct = null, ExcludedSessions = 0 };
var daily = new List<DeviceJourneyDailyAggregate>();
// Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap).
AssertWireIdentical(
new { success = true, windowDays = 30, totals, daily, repeatDevices = (List<object>?)null },
new DeviceJourneyMetricsResponse { Success = true, WindowDays = 30, Totals = totals, Daily = daily, RepeatDevices = null });
}
[Fact]
public void DeviceJourneyRepeatDevice_matches_the_anonymous_row()
{
var started = new DateTime(2026, 8, 29, 8, 0, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
windowDays = 30,
totals = new DeviceJourneyWindowTotals(),
daily = new List<DeviceJourneyDailyAggregate>(),
repeatDevices = new[]
{
new
{
serialNumber = "SER123",
manufacturer = "Contoso",
model = "Book 3",
attempts = 3,
journeyCount = 2,
lastStatus = "Failed",
lastSessionId = "22222222-2222-2222-2222-222222222222",
lastStartedAt = started,
lastFailureReason = "ESP timeout",
},
},
},
new DeviceJourneyMetricsResponse
{
Success = true,
WindowDays = 30,
Totals = new DeviceJourneyWindowTotals(),
Daily = new List<DeviceJourneyDailyAggregate>(),
RepeatDevices = new[]
{
new DeviceJourneyRepeatDevice
{
SerialNumber = "SER123",
Manufacturer = "Contoso",
Model = "Book 3",
Attempts = 3,
JourneyCount = 2,
LastStatus = "Failed",
LastSessionId = "22222222-2222-2222-2222-222222222222",
LastStartedAt = started,
LastFailureReason = "ESP timeout",
},
},
});
}
// ── config/{tenantId}/feature-flags ──────────────────────────────────────────────────
[Fact]
public void TenantFeatureFlagsResponse_matches_the_anonymous_shape_incl_null_omissions()
{
AssertWireIdentical(
new
{
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
validateAutopilotDevice = true,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
enableIntegrityBypassAnalyzer = true,
unrestrictedMode = false,
edition = "community",
isTrial = false,
trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes
trialAvailable = true,
contactEmailSet = false,
entitlements = new
{
retentionCapDays = 90,
userRateLimitPerMinute = (int?)null, // platform default → key vanishes
delegatedAdminAllowed = false,
mcpUsagePlan = "community",
},
},
new TenantFeatureFlagsResponse
{
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
ValidateAutopilotDevice = true,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
EnableIntegrityBypassAnalyzer = true,
UnrestrictedMode = false,
Edition = "community",
IsTrial = false,
TrialExpiresUtc = null,
TrialAvailable = true,
ContactEmailSet = false,
Entitlements = new TenantFeatureEntitlements
{
RetentionCapDays = 90,
UserRateLimitPerMinute = null,
DelegatedAdminAllowed = false,
McpUsagePlan = "community",
},
});
}
// ── diagnostics/paths ────────────────────────────────────────────────────────────────
[Fact]
public void DiagnosticsPathsResponse_matches_the_anonymous_shape()
{
var globalPaths = new List<DiagnosticsLogPath>();
AssertWireIdentical(
new
{
builtIn = new[]
{
new
{
id = "AgentLogs",
zipFolder = "AgentLogs",
sourceFolder = "%ProgramData%/AutopilotMonitor",
patterns = new[] { "*.log" },
includeSubfolders = false,
description = "Agent log files",
condition = "Always",
},
},
globalPaths,
},
new DiagnosticsPathsResponse
{
BuiltIn = new[]
{
new DiagnosticsBuiltInSectionWire
{
Id = "AgentLogs",
ZipFolder = "AgentLogs",
SourceFolder = "%ProgramData%/AutopilotMonitor",
Patterns = new[] { "*.log" },
IncludeSubfolders = false,
Description = "Agent log files",
Condition = "Always",
},
},
GlobalPaths = globalPaths,
});
}
// ── sessions restore / delete-queued ─────────────────────────────────────────────────
[Fact]
public void SessionRestoreResponse_matches_the_anonymous_shape_incl_null_omissions()
{
var rows = new Dictionary<string, int>(StringComparer.Ordinal) { ["Sessions"] = 1 };
var empty = new Dictionary<string, int>(StringComparer.Ordinal);
AssertWireIdentical(
new
{
success = true,
outcome = "Restored",
mode = (string?)"full",
message = (string?)null, // clean success → key vanishes
currentState = (string?)null,
pendingManifestId = (string?)null,
rowsRestoredByTable = rows,
rowsSkippedByTable = empty,
wouldRestoreByTable = empty,
inventoryReIncrements = 2,
durationMs = 1234L,
},
new SessionRestoreResponse
{
Success = true,
Outcome = "Restored",
Mode = "full",
Message = null,
CurrentState = null,
PendingManifestId = null,
RowsRestoredByTable = rows,
RowsSkippedByTable = empty,
WouldRestoreByTable = empty,
InventoryReIncrements = 2,
DurationMs = 1234L,
});
}
[Fact]
public void SessionDeletionQueuedResponse_matches_the_anonymous_Enqueued_arm()
{
AssertWireIdentical(
new
{
success = true,
status = "queued",
manifestId = (string?)"01J0123456789ABCDEFGHIJKLM",
message = "Cascade deletion queued; worker will drain asynchronously.",
},
new SessionDeletionQueuedResponse
{
Success = true,
Status = "queued",
ManifestId = "01J0123456789ABCDEFGHIJKLM",
Message = "Cascade deletion queued; worker will drain asynchronously.",
});
}
// ── feedback ─────────────────────────────────────────────────────────────────────────
[Fact]
public void Feedback_responses_match_the_anonymous_shapes()
{
AssertWireIdentical(new { eligible = true }, new FeedbackEligibilityResponse { Eligible = true });
AssertWireIdentical(
new
{
feedback = new[]
{
new
{
type = "InApp",
upn = "alice@contoso.com",
tenantId = "11111111-1111-1111-1111-111111111111",
displayName = "Alice",
rating = (int?)5,
comment = (string?)"Great",
dismissed = false,
submitted = true,
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
historyRowKey = (string?)null, // in-app entry → key vanishes
domainName = (string?)null,
},
},
},
new FeedbackListResponse
{
Feedback = new[]
{
new FeedbackEntryWire
{
Type = "InApp",
Upn = "alice@contoso.com",
TenantId = "11111111-1111-1111-1111-111111111111",
DisplayName = "Alice",
Rating = 5,
Comment = "Great",
Dismissed = false,
Submitted = true,
InteractedAt = "2026-08-30T10:00:00.0000000Z",
HistoryRowKey = null,
DomainName = null,
},
},
});
}
// ── global/backups (custom serializer options) ───────────────────────────────────────
[Fact]
public void ListBackupsResponse_matches_the_anonymous_shape_under_backup_options()
{
var ids = new List<string> { "20260831T010203Z_abcd1234" };
var expected = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
var actual = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
Assert.Equal(expected, actual);
}
// ── apps list / analytics / sessions ─────────────────────────────────────────────────
[Fact]
public void AppsListResponse_matches_the_legacy_and_paged_shapes()
{
var item = new AppsListItem
{
AppName = "Contoso App",
AppType = "Win32",
TotalInstalls = 5,
Succeeded = 3,
Skipped = 1,
Unmeasured = 0,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 60.0,
MaxDurationSeconds = 120,
AvgDownloadBytes = 1024L,
Trend = "stable",
TrendDelta = null, // under 5 finished installs in a half → key vanishes
LastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
var anonymousItem = new
{
appName = "Contoso App",
appType = "Win32",
totalInstalls = 5,
succeeded = 3,
skipped = 1,
unmeasured = 0,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 60.0,
maxDurationSeconds = 120,
avgDownloadBytes = 1024L,
trend = "stable",
trendDelta = (double?)null,
lastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
// Legacy mode: no paging keys at all.
AssertWireIdentical(
new { success = true, totalApps = 1, totalInstalls = 5, collisionExcluded = 0, windowDays = 30, apps = new[] { anonymousItem } },
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Apps = new[] { item },
});
// Paged mode incl. last-page nextLink omission.
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 5,
collisionExcluded = 0,
windowDays = 30,
count = 1,
offset = 0,
pageSize = 100,
apps = new[] { anonymousItem },
nextLink = (string?)null,
},
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Count = 1,
Offset = 0,
PageSize = 100,
Apps = new[] { item },
NextLink = null,
});
}
[Fact]
public void AppAnalyticsResponse_matches_the_empty_window_shape()
{
var regressions = new List<AppVersionRegressionAlert>();
AssertWireIdentical(
new
{
success = true,
appName = "Contoso App",
appType = string.Empty,
windowDays = 30,
collisionExcluded = 0,
bucket = "day",
summary = new
{
totalInstalls = 0,
succeeded = 0,
skipped = 0,
unmeasured = 0,
failed = 0,
failureRate = 0.0,
avgDurationSeconds = 0.0,
p95DurationSeconds = 0,
avgDownloadBytes = 0L,
trend = "stable",
trendDelta = (double?)null,
flakinessScore = 0.0,
},
timeSeries = Array.Empty<object>(),
versionBreakdown = Array.Empty<object>(),
installerPhaseBreakdown = Array.Empty<object>(),
topFailureCodes = Array.Empty<object>(),
detectionLiesCount = 0,
deviceModelBreakdown = Array.Empty<object>(),
versionRegressions = regressions,
},
new AppAnalyticsResponse
{
Success = true,
AppName = "Contoso App",
AppType = string.Empty,
WindowDays = 30,
CollisionExcluded = 0,
Bucket = "day",
Summary = new AppAnalyticsSummary
{
TotalInstalls = 0,
Succeeded = 0,
Skipped = 0,
Unmeasured = 0,
Failed = 0,
FailureRate = 0.0,
AvgDurationSeconds = 0.0,
P95DurationSeconds = 0,
AvgDownloadBytes = 0L,
Trend = "stable",
TrendDelta = null,
FlakinessScore = 0.0,
},
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
DetectionLiesCount = 0,
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
VersionRegressions = regressions,
});
}
[Fact]
public void AppAnalytics_inner_rows_match_their_anonymous_shapes()
{
var bucketStart = new DateTime(2026, 8, 29, 0, 0, 0, DateTimeKind.Utc);
var wire = ApiJsonOptions.Create();
Assert.Equal(
JsonSerializer.Serialize(new
{
bucketStart,
installs = 4,
succeeded = 3,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 90.0,
}, wire),
JsonSerializer.Serialize(new AppAnalyticsTimeBucket
{
BucketStart = bucketStart,
Installs = 4,
Succeeded = 3,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 90.0,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
appVersion = "1.2.3",
installs = 4,
failed = 1,
failureRate = 25.0,
measuredInstalls = 3,
medianDurationSeconds = 60,
p95DurationSeconds = 110,
}, wire),
JsonSerializer.Serialize(new AppVersionBreakdownItem
{
AppVersion = "1.2.3",
Installs = 4,
Failed = 1,
FailureRate = 25.0,
MeasuredInstalls = 3,
MedianDurationSeconds = 60,
P95DurationSeconds = 110,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new { phase = "DownloadStart", failed = 2 }, wire),
JsonSerializer.Serialize(new AppInstallerPhaseCount { Phase = "DownloadStart", Failed = 2 }, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
code = "0x80070005",
exitCode = (int?)5,
count = 2,
sampleMessage = "Access denied",
}, wire),
JsonSerializer.Serialize(new AppAnalyticsFailureCode
{
Code = "0x80070005",
ExitCode = 5,
Count = 2,
SampleMessage = "Access denied",
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
manufacturer = "Contoso",
model = "Book 3",
installs = 8,
failed = 2,
failureRate = 25.0,
liftVsBaseline = 1.5,
}, wire),
JsonSerializer.Serialize(new AppDeviceModelBreakdownItem
{
Manufacturer = "Contoso",
Model = "Book 3",
Installs = 8,
Failed = 2,
FailureRate = 25.0,
LiftVsBaseline = 1.5,
}, wire));
}
[Fact]
public void AppSessionsResponse_matches_the_anonymous_shape()
{
var started = new DateTime(2026, 8, 30, 7, 30, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
total = 1,
offset = 0,
limit = 50,
items = new[]
{
new
{
sessionId = "22222222-2222-2222-2222-222222222222",
tenantId = "11111111-1111-1111-1111-111111111111",
deviceName = "DESKTOP-1",
manufacturer = "Contoso",
model = "Book 3",
appVersion = "1.2.3",
status = "Failed",
installerPhase = "Install",
failureCode = "0x80070005",
exitCode = (int?)5,
attemptNumber = 2,
startedAt = started,
durationSeconds = 90,
installPassCount = 2,
},
},
},
new AppSessionsResponse
{
Success = true,
Total = 1,
Offset = 0,
Limit = 50,
Items = new[]
{
new AppSessionItem
{
SessionId = "22222222-2222-2222-2222-222222222222",
TenantId = "11111111-1111-1111-1111-111111111111",
DeviceName = "DESKTOP-1",
Manufacturer = "Contoso",
Model = "Book 3",
AppVersion = "1.2.3",
Status = "Failed",
InstallerPhase = "Install",
FailureCode = "0x80070005",
ExitCode = 5,
AttemptNumber = 2,
StartedAt = started,
DurationSeconds = 90,
InstallPassCount = 2,
},
},
});
}
}
@@ -0,0 +1,379 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Regression guard against anonymous-object API SUCCESS responses (successor of the retired
/// OkAsyncBaselineGuardTests). Anonymous success bodies ship 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 helper + 134 raw sites. That debt was migrated to typed DTOs in
/// 2026-08-31 (feat/typed-api-contract), so both per-file baselines are now EMPTY and any
/// match is a straight failure:
/// - Regex A: <c>OkAsync/CreatedAsync/JsonAsync(new { ... })</c> through ResponseHelper
/// (typed initializers <c>OkAsync(new SomeResponse { ... })</c> do not match),
/// - Regex B: raw success <c>WriteAsJsonAsync(new { ... })</c>. Error bodies — first
/// property <c>error</c>/<c>message</c>, or literal <c>success = false</c> — are
/// tolerated and stay anonymous by design (one shape).
/// Endpoints return typed DTOs implementing <see cref="IApiResponse"/>
/// (AutopilotMonitor.Shared.Models, exported to TypeScript by SharedManifestParityTests),
/// and each conversion carries an ordinal old-vs-new proof in the *WireParityTests files.
/// </summary>
public class TypedResponseGuardTests
{
// Lookbehind keeps 'JsonAsync(' from matching as the suffix of 'WriteAsJsonAsync('.
private static readonly Regex AnonymousHelperCall =
new(@"(?<![A-Za-z])(OkAsync|CreatedAsync|JsonAsync)\(\s*new\s*\{", RegexOptions.Compiled);
private static readonly Regex AnonymousWriteAsJson =
new(@"WriteAsJsonAsync\(\s*new\s*\{", RegexOptions.Compiled);
/// <summary>
/// EMPTY since 2026-08-31 — every anonymous helper success body is typed (the migration
/// started from the frozen 2026-08-13 baseline of 44 sites / 34 files). Any entry that
/// would need to be ADDED here is a regression: return a typed DTO instead.
/// </summary>
private static readonly Dictionary<string, int> HelperBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// EMPTY since 2026-08-31 — every anonymous success WriteAsJsonAsync body is typed (the
/// migration started from the frozen 2026-08-30 baseline of 134 sites / 76 files). Any
/// entry that would need to be ADDED here is a regression: return a typed DTO instead.
/// </summary>
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
// response ship untyped anyway and are ratcheted here with EMPTY baselines:
// C: the literal parked in a variable first (`var result = new { ... };` →
// `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration,
// D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`),
// E: builder methods declared `object` / `Task<object>` returning `new { ... }`
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
private static readonly Regex WriteAsJsonIdentifier =
new(@"WriteAsJsonAsync\(\s*([A-Za-z_]\w*)\s*[,)]", RegexOptions.Compiled);
private static readonly Regex SerializeAnonymous =
new(@"JsonSerializer\.Serialize(?:<[^>]+>)?\(\s*new\s*\{", RegexOptions.Compiled);
private static readonly Regex ObjectReturningBuilder =
new(@"\b(?:object|Task<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is
/// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt.
/// Everything else must be a typed DTO (error shapes are tolerated by the shape check).
/// </summary>
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
};
/// <summary>
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
/// </summary>
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
};
[Fact]
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
{
AssertRatchet(
file => AnonymousHelperCall.Matches(file).Count,
HelperBaseline,
"anonymous OkAsync/CreatedAsync/JsonAsync(new { ... })");
}
[Fact]
public void Anonymous_WriteAsJsonAsync_success_bodies_never_exceed_the_frozen_baseline()
{
AssertRatchet(
text => AnonymousWriteAsJson.Matches(text)
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
WriteBaseline,
"anonymous success WriteAsJsonAsync(new { ... })");
}
[Fact]
public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged()
{
AssertRatchet(
text => WriteAsJsonIdentifier.Matches(text).Count(m =>
{
var ident = m.Groups[1].Value;
if (ident == "new") return false;
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
var a = assign.Match(text);
// Tolerate error-shaped variables the same way inline literals are tolerated.
return a.Success && !IsErrorShape(text, a.Index + a.Length);
}),
IdentifierBaseline,
"WriteAsJsonAsync(<variable holding an anonymous object>)");
}
[Fact]
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
{
AssertRatchet(
text => SerializeAnonymous.Matches(text)
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
SerializeBaseline,
"anonymous success JsonSerializer.Serialize(new { ... })");
}
[Fact]
public void Object_returning_response_builders_are_flagged()
{
AssertRatchet(
text => ObjectReturningBuilder.Matches(text).Count,
ObjectBuilderBaseline,
"object/Task<object>-returning Build*/Compute*/*Payload/*Response method");
}
/// <summary>
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
/// properties, so any base class would silently reorder JSON keys — and key order is part
/// of the wire contract (MCP hands raw JSON to an LLM). Declaration order == wire order.
/// DTOs also must live in the Shared assembly, where SharedManifestParityTests exports them.
/// </summary>
[Fact]
public void Every_IApiResponse_implementer_is_flat_and_lives_in_Shared()
{
var sharedAssembly = typeof(IApiResponse).Assembly;
var functionsAssembly = typeof(AutopilotMonitor.Functions.Helpers.ResponseHelper).Assembly;
var strays = functionsAssembly.GetTypes()
.Where(t => typeof(IApiResponse).IsAssignableFrom(t) && !t.IsInterface)
.Select(t => t.FullName)
.ToList();
Assert.True(strays.Count == 0,
"IApiResponse implementers must live in AutopilotMonitor.Shared (manifest export):\n "
+ string.Join("\n ", strays));
var nonFlat = sharedAssembly.GetTypes()
.Where(t => typeof(IApiResponse).IsAssignableFrom(t) && !t.IsInterface)
.Where(t => t.BaseType != typeof(object))
.Select(t => $"{t.FullName} : {t.BaseType?.FullName}")
.ToList();
Assert.True(nonFlat.Count == 0,
"IApiResponse implementers must derive directly from object (key-order protection):\n "
+ string.Join("\n ", nonFlat));
}
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
// wire type carrying an `object` (or collection-of-object) property hides that slot's
// shape from the manifest exactly like an anonymous body would. `[ProjectedItems]` slots
// (fields=-projections) and dictionary VALUES (heterogeneous bags by design, e.g.
// HealthCheck.Details) are exempt.
/// <summary>
/// The deliberately-open object slots. Every other object-typed property on a reachable
/// wire type is a regression: give the slot a concrete Shared type.
/// </summary>
private static readonly HashSet<string> ObjectSlotBaseline = new(StringComparer.Ordinal)
{
// Matched: heterogeneous evidence dictionary; not matched: the evaluator's reason string.
"AutopilotMonitor.Shared.Models.RuleDryRunCondition.Evidence",
};
[Fact]
public void No_reachable_wire_type_property_is_object_typed_outside_the_baseline()
{
var shared = typeof(IApiResponse).Assembly;
var roots = shared.GetTypes().Where(t =>
!t.IsInterface && !t.IsAbstract &&
(typeof(IApiResponse).IsAssignableFrom(t) ||
t.GetCustomAttributes(typeof(WireContractAttribute), inherit: false).Length > 0));
var seen = new HashSet<Type>();
var queue = new Queue<Type>(roots);
var offenders = new List<string>();
var baselineHits = new HashSet<string>(StringComparer.Ordinal);
while (queue.Count > 0)
{
var type = queue.Dequeue();
if (!seen.Add(type))
continue;
foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
{
if (prop.GetCustomAttributes(typeof(ProjectedItemsAttribute), inherit: false).Length > 0)
continue; // fields=-projection slot: IReadOnlyList<object> by design.
var slotType = prop.PropertyType;
var elementType = EnumerableElementType(slotType);
var effective = elementType ?? slotType;
if (effective == typeof(object))
{
var slot = $"{type.FullName}.{prop.Name}";
if (ObjectSlotBaseline.Contains(slot))
baselineHits.Add(slot);
else
offenders.Add(slot);
}
else if (effective.Assembly == shared && effective.IsClass && effective != typeof(string))
{
queue.Enqueue(effective);
}
}
}
Assert.True(offenders.Count == 0,
"object-typed properties on reachable wire types (shape invisible to the manifest) — "
+ "give the slot a concrete Shared type or document it in ObjectSlotBaseline:\n "
+ string.Join("\n ", offenders.OrderBy(s => s, StringComparer.Ordinal)));
// Ratchet down: a baseline entry whose slot no longer exists (or is no longer object)
// must be removed, so the documented-debt list stays truthful.
var stale = ObjectSlotBaseline.Except(baselineHits).ToList();
Assert.True(stale.Count == 0,
"ObjectSlotBaseline entries that no longer match an object-typed slot — remove them:\n "
+ string.Join("\n ", stale));
}
/// <summary>
/// Element type of a generic IEnumerable&lt;T&gt; slot (arrays included), EXCEPT dictionaries —
/// dictionary values are heterogeneous bags by design and stay exempt. Null for scalars.
/// </summary>
private static Type? EnumerableElementType(Type type)
{
if (type == typeof(string))
return null;
if (type.IsArray)
return type.GetElementType();
var enumerable = new[] { type }.Concat(type.GetInterfaces())
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
if (enumerable == null)
return null;
var element = enumerable.GetGenericArguments()[0];
// KeyValuePair element ⇒ the slot is a dictionary; values are exempt by design.
if (element.IsGenericType && element.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
return null;
return element;
}
/// <summary>
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
/// including <c>success = someExpression</c> (dual success/failure sites) — counts as a
/// success body that must become a typed DTO.
/// </summary>
private static bool IsErrorShape(string text, int afterBraceIndex)
{
var i = SkipTrivia(text, afterBraceIndex);
var start = i;
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_'))
i++;
var identifier = text.Substring(start, i - start);
if (identifier is "error" or "message")
return true;
if (identifier == "success")
{
i = SkipTrivia(text, i);
if (i < text.Length && text[i] == '=' && (i + 1 >= text.Length || text[i + 1] != '='))
{
i = SkipTrivia(text, i + 1);
if (string.CompareOrdinal(text, i, "false", 0, 5) == 0 &&
(i + 5 >= text.Length || !char.IsLetterOrDigit(text[i + 5])))
return true;
}
}
return false;
}
private static int SkipTrivia(string text, int i)
{
while (i < text.Length)
{
if (char.IsWhiteSpace(text[i]))
{
i++;
}
else if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '/')
{
while (i < text.Length && text[i] != '\n') i++;
}
else
{
break;
}
}
return i;
}
private static void AssertRatchet(
Func<string, int> countSites, Dictionary<string, int> baseline, string label)
{
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 = countSites(File.ReadAllText(file));
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 {label} site(s) — return a typed response DTO instead.");
else if (count > allowed)
violations.Add($"{file}: {count} {label} 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(TypedResponseGuardTests)} (ratchet down).");
}
Assert.True(violations.Count == 0,
$"Anonymous {label} 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;
}
}
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire);
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire);
[Fact]
public void Response_sums_the_window_and_computes_share_rate_and_trend()
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
[InlineData("500", 180)]
[InlineData("14", 14)]
public void ClampDays_bounds_the_window(string? raw, int expected)
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
}
@@ -0,0 +1,739 @@
using AutopilotMonitor.Functions.Functions.Vulnerability;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proof for the Vulnerability 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. Item classes that
/// were ALREADY typed before the migration (auto-resolve items, CPE mapping rows, unmatched
/// aggregation rows) moved verbatim to Shared and are used on both sides — the envelope keys
/// are the independent fixture there.
/// </summary>
public class VulnerabilityWireParityTests
{
// ---- AutoResolveCpeMapping -----------------------------------------------------------
[Fact]
public void AutoResolveCpeMappingResponse_matches_the_bulk_outcome_shape()
{
var resolved = new List<AutoResolveResultItem>
{
new AutoResolveResultItem { SoftwareName = "7-Zip 24.08 (x64)", CpeUri = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*", Confidence = 0.92 },
};
var failed = new List<AutoResolveFailedItem>
{
new AutoResolveFailedItem { SoftwareName = "Contoso LOB Client", Reason = "no_match" },
new AutoResolveFailedItem { SoftwareName = "", Reason = "empty_name" },
};
AssertParity(
new
{
resolved,
failed,
totalProcessed = resolved.Count + failed.Count,
totalResolved = resolved.Count,
totalFailed = failed.Count
},
new AutoResolveCpeMappingResponse
{
Resolved = resolved,
Failed = failed,
TotalProcessed = resolved.Count + failed.Count,
TotalResolved = resolved.Count,
TotalFailed = failed.Count
});
}
// ---- DeleteCustomCpeMapping ----------------------------------------------------------
[Fact]
public void DeleteCustomCpeMapping_success_body_matches_SuccessMessageResponse()
{
AssertParity(
new { success = true, message = "CPE mapping deleted" },
new SuccessMessageResponse { Success = true, Message = "CPE mapping deleted" });
}
// ---- GetCpeMappings ------------------------------------------------------------------
[Fact]
public void GetCpeMappingsResponse_matches_the_mapping_listing_shape()
{
var sorted = new List<CpeMappingItem>
{
new CpeMappingItem
{
NormalizedVendor = "igor pavlov",
NormalizedProduct = "7-zip",
CpeVendor = "7-zip",
CpeProduct = "7-zip",
CpeUri = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*",
Category = "archiver",
DisplayNamePatterns = new List<string> { "7-zip*" },
PublisherPatterns = new List<string> { "igor pavlov" },
ExcludePatterns = new List<string>(),
Source = "custom",
CreatedAt = "2026-08-29T18:30:00.0000000Z",
},
};
AssertParity(
new { mappings = sorted, total = sorted.Count },
new GetCpeMappingsResponse { Mappings = sorted, Total = sorted.Count });
}
// ---- GetSoftwareInventory / GetTenantSoftwareInventory (same shape) ------------------
[Fact]
public void SoftwareInventoryResponse_matches_the_inventory_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var inventory = new List<Dictionary<string, object>>
{
new Dictionary<string, object>
{
["displayName"] = "7-Zip 24.08 (x64)",
["publisher"] = "Igor Pavlov",
["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*",
["sessionCount"] = 12,
},
new Dictionary<string, object>
{
["displayName"] = "Contoso LOB Client",
["publisher"] = "Contoso",
["cpeUri"] = "",
},
};
AssertParity(
new
{
success = true,
tenantId,
total = inventory.Count,
matched = inventory.Count(i => !string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
unmatched = inventory.Count(i => string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
inventory
},
new SoftwareInventoryResponse
{
Success = true,
TenantId = tenantId,
Total = inventory.Count,
Matched = 1,
Unmatched = 1,
Inventory = inventory
});
}
// ---- GetUnmatchedSoftware ------------------------------------------------------------
[Fact]
public void GetUnmatchedSoftwareResponse_matches_the_paged_report_shape()
{
var page = new List<UnmatchedSoftwareItem>
{
new UnmatchedSoftwareItem
{
SoftwareName = "Contoso LOB Client",
Publisher = "Contoso",
Frequency = 9,
LastSeenAt = "2026-08-30T07:15:00.0000000Z",
ExampleSessionId = "0b6f7a37-1111-4d61-9c93-0aa111111111",
NormalizedVendor = "contoso",
NormalizedVersion = "3.1.4",
},
};
var total = 41;
var skip = 0;
var take = 50;
AssertParity(
new { software = page, total, skip, take },
new GetUnmatchedSoftwareResponse { Software = page, Total = total, Skip = skip, Take = take });
}
// ---- GetVulnerabilityReport (4 sites) ------------------------------------------------
[Fact]
public void GetVulnerabilityReportResponse_matches_the_rescan_no_inventory_shape()
{
var sessionId = "3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001";
AssertParity(
new { success = true, sessionId, report = (object?)null, rescanned = true, message = "No software inventory events found for this session" },
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null, Rescanned = true, Message = "No software inventory events found for this session" });
}
[Fact]
public void GetVulnerabilityReportResponse_matches_the_rescan_fresh_report_shape_and_omits_message()
{
var sessionId = "3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002";
var freshReport = new Dictionary<string, object>
{
["totalFindings"] = 2,
["scannedAt"] = "2026-08-30T12:00:00.0000000Z",
};
AssertParity(
new { success = true, sessionId, report = freshReport, rescanned = true },
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = freshReport, Rescanned = true });
}
[Fact]
public void GetVulnerabilityReportResponse_matches_the_rescan_no_findings_shape()
{
var sessionId = "3f1c9d55-cccc-4e0e-8888-52f3aaaa0003";
AssertParity(
new { success = true, sessionId, report = (object?)null, rescanned = true, message = "Re-scan complete: no vulnerabilities found" },
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null, Rescanned = true, Message = "Re-scan complete: no vulnerabilities found" });
}
[Fact]
public void GetVulnerabilityReportResponse_matches_the_no_stored_report_shape_and_omits_rescanned()
{
var sessionId = "3f1c9d55-dddd-4e0e-8888-52f3aaaa0004";
AssertParity(
new { success = true, sessionId, report = (object?)null },
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null });
}
// ---- GetVulnerabilitySyncStatus ------------------------------------------------------
[Fact]
public void GetVulnerabilitySyncStatusResponse_matches_the_fully_populated_shape()
{
var nvdCacheEntries = 1543;
AssertParity(
new
{
kevCatalogEntries = 1312,
kevLastSyncUtc = (string?)"2026-08-30T04:00:01.0000000Z",
msrcIndexedCves = 4211,
msrcCoveredDocuments = 36,
msrcLastSyncUtc = (string?)"2026-08-30T04:00:05.0000000Z",
nvdCacheEntries,
nvdCacheLastRefreshUtc = (string?)"2026-08-30T04:10:00.0000000Z",
nvdRefreshRunning = false,
nvdLastRun = new
{
stale = 40, refreshed = 38, failed = 2,
skippedThrottled = 0, error = (string?)"NVD returned 503 for 2 CPEs",
startedUtc = "2026-08-30T04:00:10.0000000Z", finishedUtc = (string?)"2026-08-30T04:10:00.0000000Z",
},
epssLastSyncUtc = (string?)"2026-08-30T04:11:00.0000000Z",
epssRefreshRunning = false,
epssLastRun = new
{
rows = 1543, cves = 1490, scored = 1450,
rowsRewritten = 1201, error = (string?)"EPSS batch 3 timed out",
startedUtc = "2026-08-30T04:10:30.0000000Z", finishedUtc = (string?)"2026-08-30T04:11:00.0000000Z",
},
},
new GetVulnerabilitySyncStatusResponse
{
KevCatalogEntries = 1312,
KevLastSyncUtc = "2026-08-30T04:00:01.0000000Z",
MsrcIndexedCves = 4211,
MsrcCoveredDocuments = 36,
MsrcLastSyncUtc = "2026-08-30T04:00:05.0000000Z",
NvdCacheEntries = nvdCacheEntries,
NvdCacheLastRefreshUtc = "2026-08-30T04:10:00.0000000Z",
NvdRefreshRunning = false,
NvdLastRun = new NvdRefreshRunWire
{
Stale = 40, Refreshed = 38, Failed = 2,
SkippedThrottled = 0, Error = "NVD returned 503 for 2 CPEs",
StartedUtc = "2026-08-30T04:00:10.0000000Z", FinishedUtc = "2026-08-30T04:10:00.0000000Z",
},
EpssLastSyncUtc = "2026-08-30T04:11:00.0000000Z",
EpssRefreshRunning = false,
EpssLastRun = new EpssRefreshRunWire
{
Rows = 1543, Cves = 1490, Scored = 1450,
RowsRewritten = 1201, Error = "EPSS batch 3 timed out",
StartedUtc = "2026-08-30T04:10:30.0000000Z", FinishedUtc = "2026-08-30T04:11:00.0000000Z",
},
});
}
[Fact]
public void GetVulnerabilitySyncStatusResponse_omits_all_null_slots_on_a_cold_instance()
{
// config == null (no AdminConfiguration yet) and no in-memory runs: every
// nullable slot vanishes on both sides.
AssertParity(
new
{
kevCatalogEntries = 0,
kevLastSyncUtc = (string?)null,
msrcIndexedCves = 0,
msrcCoveredDocuments = 0,
msrcLastSyncUtc = (string?)null,
nvdCacheEntries = 0,
nvdCacheLastRefreshUtc = (string?)null,
nvdRefreshRunning = false,
nvdLastRun = (object?)null,
epssLastSyncUtc = (string?)null,
epssRefreshRunning = false,
epssLastRun = (object?)null,
},
new GetVulnerabilitySyncStatusResponse
{
KevCatalogEntries = 0,
KevLastSyncUtc = null,
MsrcIndexedCves = 0,
MsrcCoveredDocuments = 0,
MsrcLastSyncUtc = null,
NvdCacheEntries = 0,
NvdCacheLastRefreshUtc = null,
NvdRefreshRunning = false,
NvdLastRun = null,
EpssLastSyncUtc = null,
EpssRefreshRunning = false,
EpssLastRun = null,
});
}
[Fact]
public void GetVulnerabilitySyncStatusResponse_omits_null_error_and_finishedUtc_inside_running_runs()
{
AssertParity(
new
{
kevCatalogEntries = 1312,
kevLastSyncUtc = (string?)"2026-08-30T04:00:01.0000000Z",
msrcIndexedCves = 4211,
msrcCoveredDocuments = 36,
msrcLastSyncUtc = (string?)"2026-08-30T04:00:05.0000000Z",
nvdCacheEntries = 1543,
nvdCacheLastRefreshUtc = (string?)null,
nvdRefreshRunning = true,
nvdLastRun = new
{
stale = 40, refreshed = 12, failed = 0,
skippedThrottled = 0, error = (string?)null,
startedUtc = "2026-08-30T12:00:00.0000000Z", finishedUtc = (string?)null,
},
epssLastSyncUtc = (string?)null,
epssRefreshRunning = true,
epssLastRun = new
{
rows = 0, cves = 0, scored = 0,
rowsRewritten = 0, error = (string?)null,
startedUtc = "2026-08-30T12:01:00.0000000Z", finishedUtc = (string?)null,
},
},
new GetVulnerabilitySyncStatusResponse
{
KevCatalogEntries = 1312,
KevLastSyncUtc = "2026-08-30T04:00:01.0000000Z",
MsrcIndexedCves = 4211,
MsrcCoveredDocuments = 36,
MsrcLastSyncUtc = "2026-08-30T04:00:05.0000000Z",
NvdCacheEntries = 1543,
NvdCacheLastRefreshUtc = null,
NvdRefreshRunning = true,
NvdLastRun = new NvdRefreshRunWire
{
Stale = 40, Refreshed = 12, Failed = 0,
SkippedThrottled = 0, Error = null,
StartedUtc = "2026-08-30T12:00:00.0000000Z", FinishedUtc = null,
},
EpssLastSyncUtc = null,
EpssRefreshRunning = true,
EpssLastRun = new EpssRefreshRunWire
{
Rows = 0, Cves = 0, Scored = 0,
RowsRewritten = 0, Error = null,
StartedUtc = "2026-08-30T12:01:00.0000000Z", FinishedUtc = null,
},
});
}
// ---- IgnoreSoftware (GET / POST / DELETE) --------------------------------------------
[Fact]
public void GetIgnoredSoftwareResponse_matches_the_ignore_list_shape()
{
// Old site built anonymous items { softwareName, publisher, reason, ignoredAt }.
var anonymousItems = new List<object>
{
new
{
softwareName = "contoso lob client",
publisher = "contoso",
reason = "internal tool",
ignoredAt = "2026-08-28T09:00:00.0000000Z",
},
};
var typedItems = new List<IgnoredSoftwareItem>
{
new IgnoredSoftwareItem
{
SoftwareName = "contoso lob client",
Publisher = "contoso",
Reason = "internal tool",
IgnoredAt = "2026-08-28T09:00:00.0000000Z",
},
};
AssertParity(
new { items = anonymousItems, total = anonymousItems.Count },
new GetIgnoredSoftwareResponse { Items = typedItems, Total = typedItems.Count });
}
[Fact]
public void AddIgnoredSoftwareResponse_matches_the_added_count_shape()
{
var added = 3;
AssertParity(
new { success = true, added },
new AddIgnoredSoftwareResponse { Success = true, Added = added });
}
[Fact]
public void DeleteIgnoredSoftware_success_body_matches_SuccessMessageResponse()
{
AssertParity(
new { success = true, message = "Removed from ignore list" },
new SuccessMessageResponse { Success = true, Message = "Removed from ignore list" });
}
// ---- SaveCustomCpeMapping ------------------------------------------------------------
[Fact]
public void SaveCustomCpeMappingResponse_matches_the_saved_mapping_shape()
{
var rowKey = "igor pavlov:7-zip";
AssertParity(
new { success = true, message = "CPE mapping saved", rowKey },
new SaveCustomCpeMappingResponse { Success = true, Message = "CPE mapping saved", RowKey = rowKey });
}
// ---- TriggerEpssSync (dual-purpose success) ------------------------------------------
[Fact]
public void TriggerEpssSyncResponse_matches_the_failed_run_shape()
{
var ok = false;
string? error = "EPSS returned 500";
var durationMs = 5230;
var syncedAt = "2026-08-30T12:05:00.0000000Z";
AssertParity(
new
{
success = ok,
message = $"EPSS sync failed: {error}",
epssRows = 0,
epssCves = 0,
epssScored = 0,
epssRowsRewritten = 0,
epssError = error,
durationMs,
syncedAt
},
new TriggerEpssSyncResponse
{
Success = ok,
Message = $"EPSS sync failed: {error}",
EpssRows = 0,
EpssCves = 0,
EpssScored = 0,
EpssRowsRewritten = 0,
EpssError = error,
DurationMs = durationMs,
SyncedAt = syncedAt
});
}
[Fact]
public void TriggerEpssSyncResponse_omits_a_null_epssError_on_success()
{
var durationMs = 4100;
var syncedAt = "2026-08-30T12:06:00.0000000Z";
AssertParity(
new
{
success = true,
message = "EPSS scores refreshed: 1,450 of 1,490 cached CVEs scored, 1,201 rows updated",
epssRows = 1543,
epssCves = 1490,
epssScored = 1450,
epssRowsRewritten = 1201,
epssError = (string?)null,
durationMs,
syncedAt
},
new TriggerEpssSyncResponse
{
Success = true,
Message = "EPSS scores refreshed: 1,450 of 1,490 cached CVEs scored, 1,201 rows updated",
EpssRows = 1543,
EpssCves = 1490,
EpssScored = 1450,
EpssRowsRewritten = 1201,
EpssError = null,
DurationMs = durationMs,
SyncedAt = syncedAt
});
}
// ---- TriggerMsrcSync (dual-purpose success) ------------------------------------------
[Fact]
public void TriggerMsrcSyncResponse_matches_the_failed_run_shape()
{
string? error = "CVRF document 2026-Aug not reachable";
var durationMs = 8000;
var syncedAt = "2026-08-30T12:07:00.0000000Z";
AssertParity(
new
{
success = string.IsNullOrEmpty(error),
message = $"MSRC sync failed: {error}",
msrcCves = 0,
msrcDocs = 2,
msrcDocsFailed = 1,
msrcSkipped = false,
msrcError = error,
durationMs,
syncedAt
},
new TriggerMsrcSyncResponse
{
Success = false,
Message = $"MSRC sync failed: {error}",
MsrcCves = 0,
MsrcDocs = 2,
MsrcDocsFailed = 1,
MsrcSkipped = false,
MsrcError = error,
DurationMs = durationMs,
SyncedAt = syncedAt
});
}
[Fact]
public void TriggerMsrcSyncResponse_omits_a_null_msrcError_on_success()
{
var durationMs = 6500;
var syncedAt = "2026-08-30T12:08:00.0000000Z";
AssertParity(
new
{
success = true,
message = "MSRC index refreshed",
msrcCves = 4211,
msrcDocs = 36,
msrcDocsFailed = 0,
msrcSkipped = false,
msrcError = (string?)null,
durationMs,
syncedAt
},
new TriggerMsrcSyncResponse
{
Success = true,
Message = "MSRC index refreshed",
MsrcCves = 4211,
MsrcDocs = 36,
MsrcDocsFailed = 0,
MsrcSkipped = false,
MsrcError = null,
DurationMs = durationMs,
SyncedAt = syncedAt
});
}
// ---- TriggerNvdCacheRefresh ----------------------------------------------------------
[Fact]
public void TriggerNvdCacheRefreshResponse_matches_the_accepted_shape()
{
var startedAt = "2026-08-30T12:09:00.0000000Z";
AssertParity(
new
{
success = true,
message = "NVD cache refresh started — stale entries are re-queried sequentially; check the sync status for progress",
startedAt
},
new TriggerNvdCacheRefreshResponse
{
Success = true,
Message = "NVD cache refresh started — stale entries are re-queried sequentially; check the sync status for progress",
StartedAt = startedAt
});
}
// ---- TriggerVulnerabilityDataSync ----------------------------------------------------
[Fact]
public void TriggerVulnerabilityDataSyncResponse_matches_the_reseed_shape_with_errors()
{
var cpeSeedEntries = 0;
string? cpeSeedError = "GitHub fetch failed: 403";
var cpeCommunityEntries = 0;
string? cpeCommunityError = "No community mappings available: 404";
var syncedAt = "2026-08-30T12:10:00.0000000Z";
AssertParity(
new
{
success = true,
message = "Vulnerability data sync completed",
kevCatalogEntries = 1312,
cpeSeedEntries,
cpeSeedError = cpeSeedError,
cpeCommunityEntries,
cpeCommunityError = cpeCommunityError,
syncedAt
},
new TriggerVulnerabilityDataSyncResponse
{
Success = true,
Message = "Vulnerability data sync completed",
KevCatalogEntries = 1312,
CpeSeedEntries = cpeSeedEntries,
CpeSeedError = cpeSeedError,
CpeCommunityEntries = cpeCommunityEntries,
CpeCommunityError = cpeCommunityError,
SyncedAt = syncedAt
});
}
[Fact]
public void TriggerVulnerabilityDataSyncResponse_omits_null_reseed_errors()
{
var cpeSeedEntries = 180;
string? cpeSeedError = null;
var cpeCommunityEntries = 12;
string? cpeCommunityError = null;
var syncedAt = "2026-08-30T12:11:00.0000000Z";
AssertParity(
new
{
success = true,
message = "Vulnerability data sync completed",
kevCatalogEntries = 1312,
cpeSeedEntries,
cpeSeedError = cpeSeedError,
cpeCommunityEntries,
cpeCommunityError = cpeCommunityError,
syncedAt
},
new TriggerVulnerabilityDataSyncResponse
{
Success = true,
Message = "Vulnerability data sync completed",
KevCatalogEntries = 1312,
CpeSeedEntries = cpeSeedEntries,
CpeSeedError = null,
CpeCommunityEntries = cpeCommunityEntries,
CpeCommunityError = null,
SyncedAt = syncedAt
});
}
// ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary -------------------------
// The endpoint always serialized the named CveExposureSummary record (never anonymous);
// it only gained the IApiResponse marker. The anonymous side here replicates the record's
// established wire shape to pin key names, order and the TotalAffectedTenants omission.
[Fact]
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
{
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
var topCves = new List<TopCve>
{
new TopCve
{
CveId = "CVE-2026-12345",
CvssSeverity = "critical",
CvssScore = 9.8,
IsKev = true,
EpssScore = 0.94,
Priority = "act",
AffectedSessions = 7,
SampleSoftware = new List<string> { "7-Zip 24.08 (x64)" },
},
};
AssertParity(
new
{
windowDays = 30,
totalAffectedSessions = 11,
totalAffectedTenants = (int?)3,
distinctCves = 3,
kevCves = 1,
severityBreakdown = breakdown,
topCves,
truncated = false,
},
new CveExposureSummary
{
WindowDays = 30,
TotalAffectedSessions = 11,
TotalAffectedTenants = 3,
DistinctCves = 3,
KevCves = 1,
SeverityBreakdown = breakdown,
TopCves = topCves,
Truncated = false,
});
}
[Fact]
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
{
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
var topCves = new List<TopCve>();
AssertParity(
new
{
windowDays = 7,
totalAffectedSessions = 0,
totalAffectedTenants = (int?)null,
distinctCves = 0,
kevCves = 0,
severityBreakdown = breakdown,
topCves,
truncated = true,
},
new CveExposureSummary
{
WindowDays = 7,
TotalAffectedSessions = 0,
TotalAffectedTenants = null,
DistinctCves = 0,
KevCves = 0,
SeverityBreakdown = breakdown,
TopCves = topCves,
Truncated = true,
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,327 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Builds the <c>types</c> section of shared-manifests.json (schemaVersion 2): a
/// machine-readable description of every HTTP wire type in AutopilotMonitor.Shared —
/// roots are all <see cref="IApiResponse"/> implementers plus every
/// <see cref="WireContractAttribute"/>-marked type, closed transitively over
/// references, collections, dictionaries, enums and <see cref="ProjectedItemsAttribute"/>
/// item types. The web generator (scripts/generate-shared-manifest-types.js) turns it
/// into utils/wire-types.generated.ts.
///
/// Field names honour <see cref="JsonPropertyNameAttribute"/> and fall back to the
/// camelCase policy; <see cref="JsonIgnoreAttribute"/> members are skipped; a nullable
/// slot is exported <c>optional</c> (WhenWritingNull omits its key); get-only members are
/// <c>readonly</c>. Unmappable shapes (non-string dictionary keys, foreign BCL classes)
/// FAIL the build loudly instead of degrading to <c>unknown</c> — an unknown that nobody
/// chose is drift waiting to happen.
/// </summary>
internal static class WireTypeManifestBuilder
{
// ── public entry ────────────────────────────────────────────────────────
public static SortedDictionary<string, object?> BuildTypesSection()
{
var assembly = typeof(IApiResponse).Assembly;
var docs = XmlDocs.Load(assembly);
var roots = assembly.GetTypes()
.Where(t => !t.IsInterface && !t.IsAbstract &&
(typeof(IApiResponse).IsAssignableFrom(t) ||
t.GetCustomAttribute<WireContractAttribute>() != null))
.OrderBy(t => t.Name, StringComparer.Ordinal)
.ToList();
var result = new SortedDictionary<string, object?>(StringComparer.Ordinal);
var visited = new Dictionary<string, Type>(StringComparer.Ordinal);
var queue = new Queue<Type>(roots);
while (queue.Count > 0)
{
var type = queue.Dequeue();
if (visited.TryGetValue(type.Name, out var seen))
{
Assert.True(seen == type,
$"Wire type simple-name collision: {seen.FullName} vs {type.FullName}. " +
"TS interfaces are flat-named — rename one of them.");
continue;
}
visited[type.Name] = type;
result[type.Name] = type.IsEnum
? BuildEnum(type, docs)
: BuildObject(type, docs, queue);
}
return result;
}
// ── object / enum shapes ────────────────────────────────────────────────
private static Dictionary<string, object?> BuildEnum(Type type, XmlDocs docs)
{
var node = new Dictionary<string, object?> { ["kind"] = "enum" };
var doc = docs.Summary("T:" + type.FullName);
if (doc != null) node["doc"] = doc;
// Declaration order kept — string-enum unions have no ordinal semantics on the
// wire (JsonStringEnumConverter), but a stable order keeps diffs minimal.
node["members"] = Enum.GetNames(type);
return node;
}
private static Dictionary<string, object?> BuildObject(Type type, XmlDocs docs, Queue<Type> queue)
{
var node = new Dictionary<string, object?> { ["kind"] = "object" };
var typeDoc = docs.Summary("T:" + type.FullName);
if (typeDoc != null) node["doc"] = typeDoc;
var nullability = new NullabilityInfoContext();
var fields = new List<object?>();
// System.Text.Json serializes the DERIVED type's declared properties before the base
// type's — walk the hierarchy most-derived-first so field order == wire order even for
// the legacy inherited wire types (LocationSessionRow : SessionSummary). New DTOs stay
// flat (TypedResponseGuardTests enforces that for every IApiResponse envelope).
var properties = new List<PropertyInfo>();
for (var t = type; t != null && t != typeof(object); t = t.BaseType)
{
Assert.True(t == type || t.Assembly == typeof(IApiResponse).Assembly,
$"{type.FullName}: base type {t.FullName} is outside AutopilotMonitor.Shared.");
properties.AddRange(t
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.GetMethod != null && p.GetMethod.IsPublic && p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>() is not { Condition: JsonIgnoreCondition.Always })
.OrderBy(p => p.MetadataToken)); // declaration order == wire order
}
foreach (var p in properties)
{
var field = new Dictionary<string, object?>
{
["name"] = p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName(p.Name),
};
var projected = p.GetCustomAttribute<ProjectedItemsAttribute>();
if (projected != null)
{
queue.Enqueue(projected.ItemType);
field["type"] = new Dictionary<string, object?>
{
["kind"] = "array",
["element"] = new Dictionary<string, object?>
{
["kind"] = "partial",
["name"] = projected.ItemType.Name,
},
};
}
else
{
var info = nullability.Create(p);
var descriptor = Describe(info.Type, info, queue, $"{type.Name}.{p.Name}");
// Property-level null never reaches the wire (WhenWritingNull omits the KEY —
// that's what "optional" says). Explicit nulls only exist INSIDE collections
// and dictionary values, so the flag stays on nested descriptors only.
descriptor.Remove("nullable");
field["type"] = descriptor;
}
if (IsOptional(p, nullability)) field["optional"] = true;
if (p.SetMethod == null || !p.SetMethod.IsPublic) field["readonly"] = true;
var doc = docs.Summary($"P:{type.FullName}.{p.Name}");
if (doc != null) field["doc"] = doc;
fields.Add(field);
}
node["fields"] = fields;
return node;
}
/// <summary>A nullable slot's key is ABSENT under WhenWritingNull ⇒ optional in TS.</summary>
private static bool IsOptional(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;
}
// ── type descriptors ────────────────────────────────────────────────────
private static Dictionary<string, object?> Describe(
Type type, NullabilityInfo? info, Queue<Type> queue, string context)
{
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
{
var inner = Describe(underlying, null, queue, context);
inner["nullable"] = true;
return inner;
}
if (Primitive(type) is { } primitive)
{
var node = Descriptor("primitive", primitive);
if (info != null && !type.IsValueType && type != typeof(object) &&
info.ReadState == NullabilityState.Nullable)
node["nullable"] = true;
return node;
}
if (type.IsEnum)
{
queue.Enqueue(type);
// JsonStringEnumConverter is part of the wire settings — enums travel as name strings.
var node = Descriptor("enum", type.Name);
return node;
}
if (type.IsArray)
{
var element = Describe(type.GetElementType()!, info?.ElementType, queue, context + "[]");
return new Dictionary<string, object?> { ["kind"] = "array", ["element"] = element };
}
if (type.IsGenericType)
{
var args = type.GetGenericTypeArguments(info);
if (IsDictionaryLike(type))
{
Assert.True(args[0].Type == typeof(string),
$"{context}: dictionary key must be string for the JSON wire, found {args[0].Type.Name}.");
var value = Describe(args[1].Type, args[1].Info, queue, context + "{}");
return new Dictionary<string, object?> { ["kind"] = "map", ["value"] = value };
}
if (IsEnumerableLike(type))
{
var element = Describe(args[0].Type, args[0].Info, queue, context + "[]");
return new Dictionary<string, object?> { ["kind"] = "array", ["element"] = element };
}
}
if (type.Assembly == typeof(IApiResponse).Assembly && (type.IsClass || type.IsValueType))
{
queue.Enqueue(type);
var node = Descriptor("ref", type.Name);
if (info != null && info.ReadState == NullabilityState.Nullable)
node["nullable"] = true;
return node;
}
throw new InvalidOperationException(
$"{context}: no wire mapping for {type.FullName}. Map it explicitly in " +
$"{nameof(WireTypeManifestBuilder)} (or move the type into AutopilotMonitor.Shared) — " +
"silent 'unknown' fallbacks are drift.");
}
private static string? Primitive(Type type)
{
if (type == typeof(string) || type == typeof(char)) return "string";
if (type == typeof(bool)) return "boolean";
if (type == typeof(int) || type == typeof(long) || type == typeof(short) ||
type == typeof(byte) || type == typeof(sbyte) || type == typeof(uint) ||
type == typeof(ulong) || type == typeof(ushort) || type == typeof(double) ||
type == typeof(float) || type == typeof(decimal)) return "number";
// Preformatted on the wire by the serializer — TS sees strings.
if (type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(Guid)) return "string";
if (type == typeof(object) || type == typeof(JsonElement)) return "unknown";
return null;
}
private static Dictionary<string, object?> Descriptor(string kind, string name)
=> new() { ["kind"] = kind, ["name"] = name };
private static bool IsDictionaryLike(Type type)
{
var def = type.GetGenericTypeDefinition();
if (def == typeof(Dictionary<,>) || def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>))
return true;
return type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>));
}
private static bool IsEnumerableLike(Type type)
=> type.GetInterfaces().Append(type).Any(i =>
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
private readonly record struct GenericArg(Type Type, NullabilityInfo? Info);
private static GenericArg[] GetGenericTypeArguments(this Type type, NullabilityInfo? info)
{
var args = type.GetGenericArguments();
var infos = info?.GenericTypeArguments;
return args.Select((a, i) => new GenericArg(a, infos != null && i < infos.Length ? infos[i] : null))
.ToArray();
}
// ── XML documentation ───────────────────────────────────────────────────
/// <summary><c>&lt;summary&gt;</c> texts from AutopilotMonitor.Shared.xml, flattened to plain text.</summary>
private sealed class XmlDocs
{
private readonly Dictionary<string, string> _summaries;
private XmlDocs(Dictionary<string, string> summaries) => _summaries = summaries;
public static XmlDocs Load(Assembly assembly)
{
var path = Path.ChangeExtension(assembly.Location, ".xml");
Assert.True(File.Exists(path),
$"XML doc file missing next to {assembly.GetName().Name}.dll — " +
"GenerateDocumentationFile must stay enabled in AutopilotMonitor.Shared.csproj " +
"(the wire-type manifest exports <summary> texts as JSDoc).");
var summaries = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var member in XDocument.Load(path).Descendants("member"))
{
var name = member.Attribute("name")?.Value;
var summary = member.Element("summary");
if (name == null || summary == null) continue;
var text = Flatten(summary);
if (text.Length > 0) summaries[name] = text;
}
return new XmlDocs(summaries);
}
public string? Summary(string memberId)
=> _summaries.TryGetValue(memberId, out var s) ? s : null;
private static string Flatten(XElement summary)
{
var parts = new List<string>();
foreach (var node in summary.Nodes())
{
switch (node)
{
case XText text:
parts.Add(text.Value);
break;
case XElement { Name.LocalName: "see" or "seealso" } el:
var cref = el.Attribute("cref")?.Value;
if (!string.IsNullOrEmpty(el.Value)) parts.Add(el.Value);
else if (cref != null) parts.Add(cref[(cref.LastIndexOf('.') + 1)..]);
else parts.Add(el.Attribute("href")?.Value ?? "");
break;
case XElement { Name.LocalName: "paramref" or "typeparamref" } el:
parts.Add(el.Attribute("name")?.Value ?? "");
break;
case XElement el:
// <c>, <b>, <para>, … — keep the visible text, drop the markup.
parts.Add(el.Value);
break;
}
}
return Regex.Replace(string.Concat(parts), @"\s+", " ").Trim();
}
}
}
@@ -914,6 +914,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty },
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
// Per-line agent binary integrity (written by build scripts via Merge).
// V2 is the only wired line; future V3 = add field set here. Retired columns
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
@@ -985,6 +986,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"),
OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false,
OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"),
OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"),
// Per-line agent binary integrity. V2 is the only wired line (V1 retired).
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
LatestAgentV2Version = entity.GetString("LatestAgentV2Version") ?? string.Empty,
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc);
public Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
public Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
=> _storage.GetMetricsSummaryAsync(tenantId, days);
public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
@@ -53,10 +53,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task<List<OpsEventEntry>> GetOpsEventsAsync(
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null)
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null,
OpsEventQueryFilters? filters = null)
{
var result = new List<OpsEventEntry>();
var filter = BuildFilter(category, dateFrom, dateTo);
var filter = BuildFilter(category, dateFrom, dateTo, filters);
var query = string.IsNullOrEmpty(filter)
? _table.QueryAsync<TableEntity>()
: _table.QueryAsync<TableEntity>(filter: filter);
@@ -71,7 +72,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task<RawPage<OpsEventEntry>> GetOpsEventsPageAsync(
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
OpsEventQueryFilters? filters = null)
{
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
try
@@ -80,7 +82,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
if (!string.IsNullOrEmpty(category))
{
var filter = BuildFilter(category, dateFrom, dateTo);
var filter = BuildFilter(category, dateFrom, dateTo, filters);
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: _table,
filter: filter,
@@ -97,7 +99,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// the first page would come entirely from the alphabetically-first
// category — defeating "newest first globally". Mirrors the same
// pattern used for cross-tenant session queries.
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation);
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
}
catch (Exception ex)
{
@@ -106,21 +108,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// Fixed list — keep in sync with OpsEventCategory. New categories are rare
// and require a deploy anyway, so a runtime probe of the table partitions
// would just trade certainty for cost.
internal static readonly string[] AllCategories = new[]
{
OpsEventCategory.Consent,
OpsEventCategory.Maintenance,
OpsEventCategory.Security,
OpsEventCategory.Tenant,
OpsEventCategory.Agent,
OpsEventCategory.Sla,
};
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
// once: Platform (Azure Monitor alerts) was written but never listed, so every PAGED
// cross-category read silently skipped it while the unpaged full-table path still showed
// it. New categories are rare and need a deploy anyway, so a runtime probe of the table
// partitions would just trade certainty for cost — the coverage test guards the list instead.
internal static readonly string[] AllCategories = OpsEventCategory.All;
private async Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
OpsEventQueryFilters? filters = null)
{
var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation);
// First-page request fans out across every category; subsequent
@@ -139,7 +136,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
var fetchTasks = activeCats.Select(async cat =>
{
continuations.TryGetValue(cat, out var catContinuation);
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey);
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey, filters);
var fetched = new List<(string RowKey, OpsEventEntry Item)>();
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: pageSize))
@@ -162,7 +159,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return new RawPage<OpsEventEntry>(items, nextRawToken);
}
private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
internal static string BuildFilterWithRowKeyBound(
string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey,
OpsEventQueryFilters? filters = null)
{
var clauses = new List<string>
{
@@ -174,13 +173,45 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
clauses.Add(BusinessTimestamp.OpsDateFromClause(ToUtc(dateFrom.Value)));
if (dateTo.HasValue)
clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value)));
AppendFieldFilters(clauses, filters);
return string.Join(" and ", clauses);
}
/// <summary>
/// Appends the optional non-key field filters (eventType / severity / minSeverity) as
/// SERVER-SIDE clauses. Both filter builders funnel through here so the paged fan-out and
/// the single-category / unpaged paths can never honour a different filter surface — a
/// drift would make the same query return different rows depending on whether a category
/// was named. minSeverity expands to an OR-set instead of a range comparison because Table
/// Storage compares strings lexicographically ("Critical" &lt; "Error" &lt; "Info" &lt;
/// "Warning"), which has nothing to do with severity order.
/// </summary>
private static void AppendFieldFilters(List<string> clauses, OpsEventQueryFilters? filters)
{
if (filters == null || filters.IsEmpty) return;
if (!string.IsNullOrEmpty(filters.EventType))
clauses.Add($"EventType eq '{filters.EventType!.Replace("'", "''")}'");
if (!string.IsNullOrEmpty(filters.Severity))
clauses.Add($"Severity eq '{filters.Severity!.Replace("'", "''")}'");
if (!string.IsNullOrEmpty(filters.MinSeverity))
{
var allowed = OpsEventSeverity.AtOrAbove(filters.MinSeverity!);
// Info is the floor — every canonical severity qualifies, so the clause would be a
// no-op that only costs query length. An unknown value yields an empty set; the
// endpoint rejects those up front (400), so this stays defensive-only.
if (allowed.Count > 0 && allowed.Count < OpsEventSeverity.All.Length)
{
var terms = allowed.Select(s => $"Severity eq '{s}'");
clauses.Add($"({string.Join(" or ", terms)})");
}
}
}
// Date windows filter on the RowKey (reverse-tick, index-backed): the system
// Timestamp is reset by storage migrations, and an OccurredUtc property filter
// would exclude rows written before that column existed.
internal static string? BuildFilter(string? category, DateTime? dateFrom, DateTime? dateTo)
internal static string? BuildFilter(
string? category, DateTime? dateFrom, DateTime? dateTo, OpsEventQueryFilters? filters = null)
{
var clauses = new List<string>();
if (!string.IsNullOrEmpty(category))
@@ -195,6 +226,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value)));
}
AppendFieldFilters(clauses, filters);
return clauses.Count == 0 ? null : string.Join(" and ", clauses);
}
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
@@ -147,10 +148,10 @@ public class AutopilotDeviceValidationConsentFunction
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
await response.WriteAsJsonAsync(new AutopilotConsentUrlResponse
{
consentUrl,
willAutoFlipHoming = funnel
ConsentUrl = consentUrl,
WillAutoFlipHoming = funnel
});
return response;
}
@@ -183,11 +184,11 @@ public class AutopilotDeviceValidationConsentFunction
});
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
await response.WriteAsJsonAsync(new AutopilotConsentStatusResponse
{
isConsented = result.IsConsented,
message = result.Message,
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
IsConsented = result.IsConsented,
Message = result.Message,
HomingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
});
return response;
}
@@ -266,12 +267,12 @@ public class AutopilotDeviceValidationConsentFunction
});
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
await response.WriteAsJsonAsync(new AutopilotAccessCheckResponse
{
accessPresent,
isTransient,
requiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
AccessPresent = accessPresent,
IsTransient = isTransient,
RequiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
HomingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
});
return response;
}
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -82,12 +83,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var result = await _service.RunAsync(table, dryRun, maxRows, continuation);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
await response.WriteAsJsonAsync(new BackfillJobRunResponse
{
success = true,
result,
triggeredBy = userEmail,
triggeredAt = DateTime.UtcNow,
Success = true,
Result = result,
TriggeredBy = userEmail,
TriggeredAt = DateTime.UtcNow,
});
return response;
}
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Offboarding;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -50,7 +51,7 @@ public class CustomsArchiveQueryFunction
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var filterTenantId = query["tenantId"];
var summaries = new Dictionary<string, RunSummary>(System.StringComparer.Ordinal);
var summaries = new Dictionary<string, CustomsArchiveRunSummary>(System.StringComparer.Ordinal);
var source = string.IsNullOrEmpty(filterTenantId)
? _archive.QueryAllAsync()
@@ -61,7 +62,7 @@ public class CustomsArchiveQueryFunction
var key = entry.PartitionKey;
if (!summaries.TryGetValue(key, out var summary))
{
summary = new RunSummary
summary = new CustomsArchiveRunSummary
{
PartitionKey = entry.PartitionKey,
TenantId = entry.TenantId,
@@ -91,7 +92,7 @@ public class CustomsArchiveQueryFunction
.ToList();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, count = runs.Count, runs });
await response.WriteAsJsonAsync(new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs });
return response;
}
@@ -108,10 +109,10 @@ public class CustomsArchiveQueryFunction
string historyRowKey)
{
var normalizedTenantId = tenantId.ToLowerInvariant();
var items = new List<EntrySummary>();
var items = new List<CustomsArchiveEntrySummary>();
await foreach (var entry in _archive.QueryByRunAsync(normalizedTenantId, historyRowKey))
{
items.Add(new EntrySummary
items.Add(new CustomsArchiveEntrySummary
{
PartitionKey = entry.PartitionKey,
RowKey = entry.RowKey,
@@ -123,7 +124,7 @@ public class CustomsArchiveQueryFunction
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, count = items.Count, entries = items });
await response.WriteAsJsonAsync(new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items });
return response;
}
@@ -148,7 +149,7 @@ public class CustomsArchiveQueryFunction
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, entry });
await response.WriteAsJsonAsync(new CustomsArchiveEntryResponse { Success = true, Entry = entry });
return response;
}
@@ -189,7 +190,7 @@ public class CustomsArchiveQueryFunction
requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, archiveRowKey);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true });
await response.WriteAsJsonAsync(new SuccessOnlyResponse { Success = true });
return response;
}
@@ -229,7 +230,7 @@ public class CustomsArchiveQueryFunction
requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, deleted);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, deleted });
await response.WriteAsJsonAsync(new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted });
return response;
}
@@ -239,25 +240,4 @@ public class CustomsArchiveQueryFunction
if (s.Length <= maxLength) return s;
return s.Substring(0, maxLength) + "…";
}
public class RunSummary
{
public string PartitionKey { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string HistoryRowKey { get; set; } = string.Empty;
public System.DateTime ArchivedAt { get; set; }
public int GatherRulesCount { get; set; }
public int AnalyzeRulesCount { get; set; }
public int ImeLogPatternsCount { get; set; }
}
public class EntrySummary
{
public string PartitionKey { get; set; } = string.Empty;
public string RowKey { get; set; } = string.Empty;
public string OriginalTable { get; set; } = string.Empty;
public string OriginalRowKey { get; set; } = string.Empty;
public System.DateTime ArchivedAt { get; set; }
public string EntityJsonPreview { get; set; } = string.Empty;
}
}

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