Compare commits

...

37 Commits

Author SHA1 Message Date
Oliver Kieselbach 69c6353fb1 chore: bump internal (plan gebaut, D-200/D-201/D-202, drift-guards) and .claude/commands (platform-health 422 marker) gitlinks 2026-09-05 23:28:16 +02:00
Oliver Kieselbach 2b9bf3cd3f backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202)
Helpers/QueryParams replaces fourteen private days/limit parsers, the three
TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites
under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage
or below-minimum means the call site's default, above-maximum clamps. Strict
(TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400
with a message that names the parameter. Integers are invariant-culture, instants are
invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent.
Defaults and ranges stay arguments at every call site.

Settled differences: the Apps windows clamp above 365 instead of falling back to 30,
days=0 or negative yields the default everywhere, the two global geo endpoints and
the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths
on the HTTP layer, a whitespace date means no filter on every route, the admin batch
sizes reject values above their maximum, and the three culture-dependent
DateTime.TryParse sites parse as UTC.

QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline:
headers, raw cells, the agent version major, the se= of a stored SAS URL);
QueryParamsTests carries the cases the removed copies pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:26:21 +02:00
Oliver Kieselbach d99e16314b agent+backend: one telemetry wire class, 422 poison for rejected items, agent responses in the typed regime (D-200/D-201)
TelemetryItemKind moves to Shared; TelemetryItem.ToWire() maps onto TelemetryItemDto
and the uploader serialises the DTO, so both ends share one wire class. The spool line
keeps writing Kind by name (StringEnumConverter registered in TelemetryItemSerializer).

IngestTelemetryFunction.PartitionBatch routes by exact kind name and collects every
item it cannot route or parse; a non-empty rejection list answers the batch with 422 +
TelemetryItemsRejectedResponse (poison, rejectedRowKeys, reason) before anything is
persisted, logs a Warning and writes the new TelemetryItemsRejected ops event. The
agent's existing poison protocol drops exactly the named RowKeys and re-uploads the
rest; before this the ingest skipped the items, answered 200 and the agent cleared
its spool.

Contract tests from both sides: TelemetryWireContractTests (agent) freezes the real
batch body under tests/fixtures/telemetry-wire/ and parses the pinned poison body;
TelemetryWireFixtureTests (backend) dispatches every frozen body with zero rejections,
requires an arm per kind and pins the poison body.

Agent-facing responses implement IApiResponse and go through ResponseHelper, request
types carry [WireContract]; plain ingest refusals use the error envelope, the
register-session and upload-url refusals keep their DTO bodies (the agent reads
ErrorCode/Message). Manifests and generated wire types regenerated (also picks up
four types HEAD had left out of the manifest).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:10:25 +02:00
Oliver Kieselbach e4203e0f15 chore: bump .claude/commands (platform-health skill, backend-logs corrections) and internal (marker-catalog rule) gitlinks; drop stale index dual-write mention in the dependency filter comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:53:14 +02:00
Oliver Kieselbach 2a5366d9c3 chore: bump internal gitlink (D-198/D-199, counters concept doc, storage-hardening plan GEBAUT)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:31:33 +02:00
Oliver Kieselbach 9c9cf24ddc backend: platform counters recomputed instead of incremented, RuleStats keyed per scope and day with CAS batches (audit F06, D-198/D-199)
PlatformStats(global,current) took a GET plus a full-row upsert without ETag
from every ingest batch: increments were lost under concurrency and a writer
clobbered fields another writer had just changed. RuleStats put every tenant's
daily rule traffic into one date partition (1,459 rows on 2026-09-04), the
writer issued four serial calls per evaluated rule without ETag, and the
reader was a cross-partition scan whose 10k cap dropped the newest dates.

- D-198: TotalEnrollments / SuccessfulEnrollments / TotalEventsProcessed have
  no hot-path writer; the two-hourly recompute is the only source. Only
  IssuesDetected still increments, through TableCasRetry (ETag + field-only
  merge, AddEntity for a missing row, conflicts as StorageCasConflict). The
  tenant counters share the helper.
- D-199: RuleStats PartitionKey "{scope}_{date}", RowKey ruleId.
  RecordRuleStatsAsync merges a session's increments per rule, reads the
  partition once and writes one transaction (UpdateReplace with ETag / Add),
  retrying on 412/409. Readers are PartitionKey ranges per scope; aggregation
  and the regression radar fan out over the registered tenants; the cleanup
  deletes per scope. RuleStats joins the offboarding composite wipe.
- Legacy rows (PartitionKey = date) are read by an extra query and cleaned by
  a date range that checks every row for a bare-date key, until 2026-12-05.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:30:58 +02:00
Oliver Kieselbach ec0d8ad735 backend: blob proxy lifetime and timing, one delegation key per process, shared KEV instance, single-flight config misses (audit F11/F12)
- DiagnosticsBlobStreamer disposes the download result on every exit path
  (it was disposed only on the 413 branch), measures open and transfer
  separately and fires DiagnosticsDownloadProxied after the copy with
  Completed=true/false.
- UserDelegationKeyCache: one user delegation key per process (24 h, re-mint
  under a lock with 2 h margin or when a SAS would outlive it) instead of one
  storage round-trip per download or upload SAS.
- KevDataService is resolved as one singleton over the typed-client handler
  pipeline; the cache warmer used to hydrate a throwaway transient instance
  while the correlation service held a cold one.
- Admin and tenant configuration services single-flight concurrent cache
  misses (Lazy<Task> per key); the MemoryCache and the in-place projection
  semantics stay untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:56 +02:00
Oliver Kieselbach fc9e9d13e6 backend: keep slow storage successes and count CAS conflicts as a metric (audit F10)
The dependency filter dropped every successful storage call and the expected
412/409 outcomes, so storage-side latency and ETag contention were invisible
until a call failed outright (seven days of App Insights held zero storage
dependency rows).

- StorageDependencyFilterProcessor keeps a successful storage call at or above
  2 s and stamps SlowStorage=true; the operation name is on the row, the table
  name is not (the Azure SDK span carries neither URL nor table).
- StorageMetrics wraps TelemetryClient.GetMetric("StorageCasConflict",
  operation, table, outcome): pre-aggregated per minute, bypasses the worker's
  adaptive sampling, null-tolerant for tests.
- The CAS loops report conflicts: session event count, session counter
  reconcile, session store, tenant stats, MCP user/tenant usage.
- TableStorageService keeps its two-argument constructors (Moq matches by
  exact arity); DI resolves the three-argument overload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:09:08 +02:00
Oliver Kieselbach d0cb7e66f4 backend: byte-aware table transactions and classified storage failures on the ingest (audit F07)
Every entity-group transaction was chunked by count only; 100 payload-heavy rows
(Signals with chunked PayloadJson, Events with 30k-char Message + DataJson) could
exceed the 4 MiB transaction cap, and the failure surfaced as an opaque 500. The
agent halves its batch only on 413 and treats 5xx as "replay later", so such a
batch wedged the device forever.

- TableTransactionBatcher: 100 actions, 3.5 MB per transaction, 1 MB per entity,
  never across partitions; an entity that cannot fit throws
  TableEntityTooLargeException. Used by the event, signal, transition, restore
  and rules writers; delete-only batches keep their count chunking.
- StorageErrors: one classifier for RequestFailedException (transient 408/429/
  5xx, payload-too-large by status or error code, already-exists incl. the
  Azurite 400 shape); the two IsAlreadyExistsStatus copies use it.
- Event writer: per-row fallback only for data-shaped rejections; throttling,
  outages and oversize batches propagate. Single and batched writer share one
  entity builder.
- IngestTelemetryFunction: payload too large -> 413, transient -> 503 with
  Retry-After: 5, anything else 500.
- TableStorageChunking.MaxPropertyChars is the one 30,000-char bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:03:07 +02:00
Oliver Kieselbach bd66480ccc chore: bump internal gitlink (remove-decision-inspector RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 05:05:36 +02:00
Oliver Kieselbach b05f1f6bcf chore: bump internal gitlink (remove-v2-index-tables RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:52 +02:00
Oliver Kieselbach 12ad9c9436 Remove the Decision Inspector: web subpage, three session read endpoints, renderer deps (D-197)
Web: app/sessions/inspector, Inspector button, inspectorUrl, legacy /sessions/{id}/inspector
redirect, SWA rewrites, api.inspector; drops @xyflow/react, dagre, @types/dagre.
Backend/Shared: GetSessionSignals/DecisionGraph/ReducerVerification functions, DecisionGraphBuilder,
ReducerVerifier, their DTOs and tests, the QueryBySessionAsync readers and SignalQueryLimits;
three catalog entries fewer. Signals/DecisionTransitions tables and ingest unchanged; operator
analysis reads them through MCP. Wire manifests regenerated (web + MCP copy).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:28 +02:00
Oliver Kieselbach c8ce7cdb37 backend: remove the V2 index tables and their dual-write pipeline (D-196)
The five cross-session index tables (SessionsByTerminal, SessionsByStage,
DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) were write-only
since M5.d: no endpoint, MCP tool or web page ever read them, while the
dual-write cost a serial queue send per signal/transition on the ingest path,
a 5-13 min full scan of Signals + DecisionTransitions every two hours and five
property-filter scans per session deletion (audit 2026-09-05 F05).

Removed: the tables from TableNames.All (schema hash changes, next cold start
runs a full pass without them), the telemetry-index-reconcile queue name,
producer, envelope, handler, queue function and 2h timer, the cross-partition
Timestamp scans on the signal/transition repositories, the EnableIndexDualWrite
flag (model, config mapping, admin UI, generated wire types), cascade steps
11-15 (later steps renumbered 11-18), the deletion wrappers and the offboarding
wipe entries. DiscriminatorPkProp stays as a step class so manifests written
before this change still resume.

Order of operations: deploy first, then delete the queue pair and the five
tables in the portal (B-88h). Deleting the tables before the deploy 500s every
delete preview/enqueue and poisons offboarding.

Submodules: .claude/commands (import_session.py), internal (D-196, docs, plan).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:27:48 +02:00
Oliver Kieselbach 56d4dd8b84 chore: bump internal gitlink (queue-trigger plan RELEASED, live verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:16:26 +02:00
Oliver Kieselbach 86fdc5aca9 backend: stateless queue consumers become [QueueTrigger] functions; webhooks go through a queue; poll loop receives one message at a time
Audit 2026-09-05 F01/F02. Analyze, VulnerabilityCorrelate, TenantAutoApprove,
IndexReconcile and ImeMsiArchive are now host-managed queue functions under
Functions/Queue bound to the DataStorage connection (identity-based, resolved
by the host): the Flex scale controller wakes an instance for a message, the
host renews the lease and owns the retry/poison ladder. The four cascade
workers with lease/CAS state stay self-polling BackgroundServices; their base
now receives one message per poll so waiting messages can no longer expire
under another worker. Terminal enrollment alerts and hardware-rejection alerts
are enqueued on notification-dispatch (channel ids only, resolved at send
time) instead of fire-and-forget sends the host could drop on scale-in.
Requires the app setting DataStorage__queueServiceUri before deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 02:52:37 +02:00
Oliver Kieselbach 87c5bc59dc chore: bump internal gitlink (error envelope RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:57:06 +02:00
Oliver Kieselbach 21b30b21c2 chore: bump internal gitlink (lessons rule block)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:39 +02:00
Oliver Kieselbach f94f1c0c4e chore: bump internal gitlink (lesson: no Front Door for the API)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:02 +02:00
Oliver Kieselbach a6a9883451 chore: bump internal gitlink (error envelope + correlation docs, D-192/D-193)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:37:57 +02:00
Oliver Kieselbach 03116a97d1 mcp: one correlation id per tool call — sent to the backend, logged, shown in every error
withToolTelemetry mints a UUID per tool call and runs the handler in a
ToolCallContext (tool name + correlation id); apiFetch stamps it as
X-Correlation-ID next to X-MCP-Tool-Name, so every backend request of the call
(pagination scans included) and the MCP_TOOL_LOGGING tool_call line carry the
same id — a row-level join between the MCP log and the backend's request rows.
ApiError reads the X-Correlation-ID response header (body correlationId as
fallback), and the error handler names it in every branch — 5xx, quota 429,
structured 4xx and the unstructured 401/403/404/429 texts that had none. The
handler reads the envelope's `code` (errorCode as the pre-envelope fallback),
the quota body's `error`, and the log proxy's `upstreamCode`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:33:44 +02:00
Oliver Kieselbach 2abc78ab6d web: send X-Correlation-ID on every API call, read the error envelope, show the reference
authenticatedFetch mints one UUID per logical call (kept across the 401 retry,
where caller headers now survive too), sends it as X-Correlation-ID — the id
the backend echoes, stamps on its request row and writes into every error body
— and tracks api_request_failed {path, method, status, correlationId} for any
final status >= 400. App Insights gets enableCorsCorrelation + W3C
distributedTracingMode scoped to the API host, so portal dependency rows and
backend request rows share an operation_Id.

fetchJson/ApiError read the envelope (error, code, correlationId, hint; the
pre-envelope `message` as fallback), describeApiError renders message +
short reference, addNotification takes an optional reference the bell renders
as "Ref …"; the error boundaries show and track Next's digest. Consumers of
renamed fields follow: delete-session parser (code ?? hint, PascalCase codes),
app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and
HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`.
InstallsTab is the first site showing the reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:29:54 +02:00
Oliver Kieselbach 6e54b52376 backend: migrate the 458 anonymous error bodies to the typed envelope helpers
Every non-2xx body in the Functions project now goes through ResponseHelper
(ErrorAsync / BadRequestAsync / NotFoundAsync / ForbiddenAsync /
UnauthorizedAsync / ConflictAsync / InternalServerErrorAsync): 408 sites by a
pattern-exact rewrite of the CreateResponse + WriteAsJsonAsync + return shape,
50 by hand. The eight private BadRequestAsync shadow helpers are gone; the raw
500 catch blocks use InternalServerErrorAsync (sanitized message, correlation
id, MCP hint). 429s keep Retry-After via retryAfterSeconds.

Specialised error bodies where a consumer reads more than the prefix:
AppHomingDeniedResponse (kebab reason codes now Constants.AppHomingReasonCodes,
probe result), TenantConfigPatchFailedResponse (backupId, drift),
QueryBackendLogsErrorResponse (upstreamCode, statusCode, source, hint,
upstream). Twenty-one message-first 200 bodies that the old error tolerance had
let through are typed too (MessageResponse, PreviewWhitelistActionResponse,
SuccessMessageResponse). New codes: PayloadTooLarge, UpstreamError,
UpstreamTimeout, the three trial-gate codes; the agent security path
(SecurityValidationExtensions) writes the envelope with the retry window.

TypedResponseGuardTests: WriteBaseline is empty again — the anonymous
WriteAsJsonAsync ratchet now covers error bodies with zero tolerated sites.
Manifest and generated TypeScript regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:21:01 +02:00
Oliver Kieselbach 7cef2ea361 backend: one typed error envelope (IApiErrorResponse) on every non-2xx body
Every error body now leads with { error, code, correlationId }: the generic
ApiErrorResponse (hint?, retryAfterSeconds?, operation?) plus the specialised
McpQuotaExceededResponse (reordered; `message` became `error`),
DelegatedSlotLimitReachedResponse (+correlationId) and the new
SessionDeletionRejectedResponse (replaces the anonymous delete arms). The
correlation id is stamped by the writers — ResponseHelper.ErrorAsync and the
new ApiErrorWriter for the middleware side (auth 401, policy 403/503, rate
limit 429, MCP quota 429, global exception 500) — never by call sites.
exceptionType and rateLimitInfo leave the wire; Constants.ApiErrorCodes is the
code vocabulary and is exported as the manifest section `apiErrorCodes`
(API_ERROR_CODES for web + MCP). CorrelationIdMiddleware names the readable
headers via Access-Control-Expose-Headers.

Guard: TypedResponseGuardTests no longer tolerates anonymous error shapes;
the 458 legacy sites in 149 files are frozen in WriteBaseline to melt down.
New facts: envelope-prefix reflection, ApiErrorWriter body/header pins,
exact-shape pins for the generic envelope and the changed specialised bodies.

Reverses the "error bodies stay anonymous" clause of D-043 (decision entry
follows with the docs stage).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:56:21 +02:00
Oliver Kieselbach 5b5ee0e8fd chore: bump internal gitlink (release verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:43:17 +02:00
Oliver Kieselbach 7bcc4f00b1 chore: bump internal gitlink (release verify 2026-09-04)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:42:33 +02:00
Oliver Kieselbach 34ec44e3d4 backend: duration evaluators refuse mixed timestamp provenance only
The first cut refused every reader-zone-fallback endpoint. The DeviceSetup line is
written before the agent starts and is backlog on every device, so that silenced
ANALYZE-ESP-001 for all agents without an era anchor (control sessions 2708e6b4 and
18d80f9c came back "not measurable" after the deploy). Two fallback endpoints share the
writer-zone error and cancel it; only a span mixing a fallback endpoint with an anchored
one (or with "now" for an open phase) is unmeasurable — the a7140f98 shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:34:15 +02:00
Oliver Kieselbach f607e21c13 agent+rules: Hybrid Join user affinity from the IME user token; IME backlog eras anchored by the bootstrap record
hybrid_login_pending now measures the absence of a real-user desktop after a reboot
(cancelled by the DesktopArrivalDetector or a real JoinInfo user), is never armed on
WhiteGlove devices (resume marker, Part-2 flag, .part1- archive) and reports the
JoinInfo placeholder as a fact only — on a Hybrid join the AD sign-in never replaces it.
New EntraUserAffinityDetector: armed at the real-user desktop on Hybrid devices, the
first IME-TOKEN-SUCCESS line after the desktop posts ime_user_token_acquired; ten
minutes without one post entra_user_affinity_pending with the IME token-failure codes.
ImeLogTracker exposes raw per-line token callbacks past the historic-replay gate;
HelloTracker surfaces User Device Registration 304/305 as device_registration_event.
ANALYZE-ID-004 v2.0.0 keys on those events (event_type not_exists gained a branch),
GATHER-DEVICE-006 follows, GATHER-DEVICE-007/008 collect the Entra operational log.
Constants + MCP catalog gain the five types (D-072), the guard test learns the
multi-line OnTraceEvent shape, gather_* outputs pass the MCP filter gate by prefix.

CMTrace: ImeLogEraPreScan/CmTraceEraTable anchor the backlog era that holds the
bootstrap execution record against the Deployed install marker (0.13-0.36 s in the
field), whole era or nothing, sibling files by local time; origin era-anchored.
Backend phase_duration/app_install_duration refuse a reader-zone-fallback endpoint.

Decisions D-190, D-191. Session a7140f98.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:12:56 +02:00
Oliver Kieselbach 5940e9fc6a auth: application principals — app-only tokens flow as app:<client-id> members (B-nuw)
A service principal (idtyp=app, typically behind a federated credential) is admitted only
with the access_as_application application permission and then keys every role table,
binding, audit actor and throttle bucket as app:<client-id>. Caps at resolution: Viewer in a
tenant, DelegatedReader on managed tenants, never a platform role; the roleless catalog tiers
are closed to applications except GET auth/mcp. MCP guard keys on the same principal key.
Members / MCP Users / Delegated Access get a User / Service principal switch; usage views
badge App; McpServicePrincipalFirstSeen ops event and PrincipalKind request dimension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:17:34 +02:00
Oliver Kieselbach 98cfd76232 plans: Pro (MSP) — a permanent-Pro managing tenant confers Pro on its managed tenants, derived at read time (B-qvu)
A tenant in the owned group of a permanent-Pro tenant is effectively Pro with source "msp":
badge "Pro (MSP)" (also over its own Pro), the Pro entitlement set without the delegation
right (no transitive delegation, no slots), no trial CTA. Nothing is written for that —
TenantConfiguration.ManagedByProTenantId is a load-time projection from a process-wide
index over the Tenant Groups table (owner tier read raw, never through the config service),
so the end of the delegation, the manager losing its permanent tier, or its offboarding
revert automatically. The one write is the retention grace anchor (ProDowngradedUtc),
stamped by ProConferralService from the four paths that end a conferring relationship.

- FeatureEntitlementCatalog.Resolve → EditionResolution { Edition, Source, OwnPro };
  Get(resolution) returns ProViaMsp for conferred Pro; the 3-argument ResolveEdition is
  gone so every caller resolves from the config (incl. the projection)
- DelegatedAdminService home gate checks the DelegatedAdminAllowed entitlement, not the edition
- TrialExpirySweep reads through the config service: a managed tenant's expired trial emits nothing
- feature-flags + plan response carry editionSource; config/all projects payingCustomer and
  managedByProTenantId; shared manifest + web/MCP codegen regenerated
- PayingCustomer: GA-only sales bookkeeping flag on the plan endpoint, no entitlement effect
- web: "Pro (MSP)" label/tooltips, plan section badge, delegated-access hints, tenant
  management chips + paying checkbox

Tests: catalog matrix, ManagedTenantProIndexTests (incl. mutual management without recursion,
fail-closed on storage error), ProConferralServiceTests, projection on every config read path,
feature-flags payload, parity facts, serialization (projection never stored), plan transitions,
edition gate, sweep. Backend 5902 green, web tsc/vitest/lint clean, MCP tsc/vitest green.

Concept doc + D-188 in internal (gitlink); customer docs in the docs repo (MCP redeploy needed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:31:48 +02:00
Oliver Kieselbach 09e68a614f web(geo): legend buckets under the Performance Map toggle which markers are shown
Click a bucket to narrow the map to it, add more with further clicks, Show all
or the last toggle returns to the full map; a Color-by switch resets it. Camera
fit and marker radius scale stay on the full location list so filtering never
moves or rescales the map. Customer docs updated in the docs repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:32:45 +02:00
Oliver Kieselbach 88290caadc security: sanitize method and decoded path in the privileged-denial record
CodeQL flagged the new denial flow (cs/log-forging #83-#87): header values and the request
path travel from the deny branch into a log line, the ops-events table and the alert
channels. Headers were already stripped of control characters and capped; the path was not,
and Request.Path is percent-DECODED, so %0A arrives as a newline. BuildPrivilegedDenial now
cleans method and path (LogSanitizer) and caps the path at 256 chars, once, before any sink.
Test pins CR/LF stripping and the cap. The remaining alerts are the known false-positive
class (structured ILogger sinks with sanitized discrete properties).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:25:52 +02:00
Oliver Kieselbach f442dce9a4 logs proxy: a rejected query is the caller's 400, so the MCP shows the store's error in full
Live check after the x29 deploy: a KQL syntax error came back as 502, and the MCP sanitizes
every 5xx down to the error code — the syntax message and the forwarded upstream JSON were
invisible, exactly the parity loss the proxy exists to avoid. Upstream 4xx (other than
401/403) now maps to 400 with message, hint and `upstream`; 401/403 (the managed identity's
missing read grant, with a hint naming the role) and store 5xx stay 502. The MCP's structured
4xx renderer appends `upstream` as a fenced block (capped), and keeps sanitizing 5xx.

Tests: status mapping theory, hint texts, upstream rendering on 400 and non-rendering on 502.
Backend 5849, MCP 693 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:14:36 +02:00
Oliver Kieselbach 4f03f39e9a chore: bump internal gitlink (q8n post-verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:11:07 +02:00
Oliver Kieselbach 38b667799c ci: workflow-only commits no longer trigger CI and CodeQL
The 9eb8cac5 filters covered gitlink bumps and prose but not the
workflow files themselves, so every edit to build-agent.yml or a deploy
workflow still cost a full CI and CodeQL run. Neither reads any other
workflow: CI runs the suites and drift guards, CodeQL analyzes C# and
TypeScript. paths-ignore cannot negate, so each file lists the other
twelve workflows explicitly and leaves itself out; .github/scripts and,
for CI, .github/codeql join the list for the same reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:05:21 +02:00
Oliver Kieselbach a5f74d6abc ci(agent): release body no longer repeats the release title
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:01:52 +02:00
Oliver Kieselbach d0e09301a1 ci(agent): release body drops the Installation and Included Components sections
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:59:49 +02:00
Oliver Kieselbach 177915ea6b chore: bump gitlinks (agent-release approval gate, plan released with v2.0.1448)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:58:39 +02:00
542 changed files with 15733 additions and 13024 deletions
+1 -11
View File
@@ -348,16 +348,6 @@ jobs:
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_NOTES: ${{ inputs.release_notes }}
RELEASE_BODY_TAIL: |
### Installation
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
3. Run `AutopilotMonitor.Agent.exe --install` to register the Scheduled Task
### Included Components
- **AutopilotMonitor.Agent.exe** - Monitoring agent (V2 release line)
- **AutopilotMonitor.SummaryDialog.exe** - Enrollment summary dialog (WPF)
- **version-v2.json** - Per-line version manifest
### Version Info
- **Version:** ${{ steps.version.outputs.version }}
- **Build Number:** ${{ steps.version.outputs.build_number }}
@@ -392,7 +382,7 @@ jobs:
$compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n"
}
$body = "## $env:RELEASE_NAME`n`n### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
$body = "### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline
+36 -2
View File
@@ -9,8 +9,8 @@ name: CI
# red run reports rather than blocks. Read the result and follow up.
#
# Path filters: commits that only bump a private submodule gitlink (internal/, infra/,
# .claude/commands) or touch prose cannot change anything the suites cover, so they are
# skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
# .claude/commands), touch prose or edit another workflow cannot change anything the
# suites cover, so they are skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
# path ('internal', no trailing slash), which 'internal/**' does NOT match — both forms
# are listed. GitHub skips only when EVERY changed path matches; a commit that touches
# docs AND code still runs.
@@ -30,6 +30,23 @@ on:
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/codeql/**'
- '.github/scripts/**'
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/codeql.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
paths-ignore:
@@ -45,6 +62,23 @@ on:
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/codeql/**'
- '.github/scripts/**'
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/codeql.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
workflow_dispatch:
permissions:
+33 -1
View File
@@ -6,7 +6,7 @@ name: CodeQL
# A gitlink bump reports the bare path ('internal', no trailing slash), which
# 'internal/**' does NOT match — both forms are listed. A commit that touches docs
# AND code still runs: GitHub skips only when EVERY changed path matches the filter.
# Same list as ci.yml; keep them in step.
# Same list as ci.yml except that each file leaves itself out; keep them in step.
on:
push:
branches: [main]
@@ -23,6 +23,22 @@ on:
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/scripts/**'
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
paths-ignore:
@@ -38,6 +54,22 @@ on:
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/scripts/**'
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
schedule:
# Weekly, so advisories published after a merge still surface. Deliberately
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
+16 -17
View File
@@ -2,14 +2,14 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ID-004",
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
"severity": "high",
"category": "identity",
"version": "1.2.0",
"version": "2.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
"evaluateOn": ["enrollment_end", "on_event:entra_user_affinity_pending"],
"baseConfidence": 60,
"confidenceThreshold": 75,
"preconditions": [
@@ -17,14 +17,14 @@
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
}
],
"conditions": [
{
"signal": "hybrid_login_overdue",
"signal": "user_affinity_pending",
"source": "event_type",
"eventType": "hybrid_login_pending",
"eventType": "entra_user_affinity_pending",
"operator": "exists",
"value": "",
"required": true
@@ -38,28 +38,27 @@
"required": true
},
{
"signal": "placeholder_still_active",
"signal": "no_user_token",
"source": "event_type",
"eventType": "aad_placeholder_user_detected",
"operator": "exists",
"eventType": "ime_user_token_acquired",
"operator": "not_exists",
"value": "",
"required": false
"required": true
}
],
"confidenceFactors": [
{ "signal": "hybrid_login_pending", "condition": "count >= 2", "weight": 20 },
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
{ "signal": "entra_user_affinity_pending", "condition": "count >= 2", "weight": 20 },
{ "signal": "entra_user_affinity_pending", "condition": "count >= 3", "weight": 10 }
],
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
"remediation": [
{
"title": "Verify user affinity on the device",
"steps": [
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
]
},
{
+16 -21
View File
@@ -2940,16 +2940,16 @@
{
"ruleId": "ANALYZE-ID-004",
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
"severity": "high",
"category": "identity",
"version": "1.2.0",
"version": "2.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"evaluateOn": [
"enrollment_end",
"on_event:hybrid_login_pending"
"on_event:entra_user_affinity_pending"
],
"baseConfidence": 60,
"confidenceThreshold": 75,
@@ -2958,14 +2958,14 @@
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
}
],
"conditions": [
{
"signal": "hybrid_login_overdue",
"signal": "user_affinity_pending",
"source": "event_type",
"eventType": "hybrid_login_pending",
"eventType": "entra_user_affinity_pending",
"operator": "exists",
"value": "",
"required": true
@@ -2979,40 +2979,35 @@
"required": true
},
{
"signal": "placeholder_still_active",
"signal": "no_user_token",
"source": "event_type",
"eventType": "aad_placeholder_user_detected",
"operator": "exists",
"eventType": "ime_user_token_acquired",
"operator": "not_exists",
"value": "",
"required": false
"required": true
}
],
"confidenceFactors": [
{
"signal": "hybrid_login_pending",
"signal": "entra_user_affinity_pending",
"condition": "count >= 2",
"weight": 20
},
{
"signal": "hybrid_login_pending",
"condition": "count >= 3",
"weight": 10
},
{
"signal": "aad_placeholder_user_detected",
"signal": "entra_user_affinity_pending",
"condition": "count >= 3",
"weight": 10
}
],
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
"remediation": [
{
"title": "Verify user affinity on the device",
"steps": [
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
]
},
{
+52 -4
View File
@@ -130,16 +130,16 @@
},
{
"ruleId": "GATHER-DEVICE-006",
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"title": "Collect dsregcmd join status on pending user affinity",
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": false,
"collectorType": "command_allowlisted",
"target": "dsregcmd /status",
"trigger": "on_event",
"triggerEventType": "hybrid_login_pending",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_dsregcmd_status",
"outputSeverity": "Info",
"tags": [
@@ -149,6 +149,54 @@
"hybrid"
]
},
{
"ruleId": "GATHER-DEVICE-007",
"title": "Collect Entra operational log on pending user affinity (event 1098)",
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"collectorType": "eventlog",
"target": "Microsoft-Windows-AAD/Operational",
"parameters": {
"eventId": "1098",
"maxEntries": "20"
},
"trigger": "on_event",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_aad_operational_events",
"outputSeverity": "Warning",
"tags": [
"device",
"entra",
"hybrid"
]
},
{
"ruleId": "GATHER-DEVICE-008",
"title": "Collect Entra operational log on pending user affinity (event 1097)",
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"collectorType": "eventlog",
"target": "Microsoft-Windows-AAD/Operational",
"parameters": {
"eventId": "1097",
"maxEntries": "20"
},
"trigger": "on_event",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_aad_operational_events",
"outputSeverity": "Warning",
"tags": [
"device",
"entra",
"hybrid"
]
},
{
"ruleId": "GATHER-ENROLL-001",
"title": "MDM enrollment diagnostic events",
+4 -4
View File
@@ -1,16 +1,16 @@
{
"$schema": "../schema/gather-rule.schema.json",
"ruleId": "GATHER-DEVICE-006",
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"title": "Collect dsregcmd join status on pending user affinity",
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": false,
"collectorType": "command_allowlisted",
"target": "dsregcmd /status",
"trigger": "on_event",
"triggerEventType": "hybrid_login_pending",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_dsregcmd_status",
"outputSeverity": "Info",
"tags": ["device", "entra", "join", "hybrid"]
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "../schema/gather-rule.schema.json",
"ruleId": "GATHER-DEVICE-007",
"title": "Collect Entra operational log on pending user affinity (event 1098)",
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"collectorType": "eventlog",
"target": "Microsoft-Windows-AAD/Operational",
"parameters": {
"eventId": "1098",
"maxEntries": "20"
},
"trigger": "on_event",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_aad_operational_events",
"outputSeverity": "Warning",
"tags": ["device", "entra", "hybrid"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "../schema/gather-rule.schema.json",
"ruleId": "GATHER-DEVICE-008",
"title": "Collect Entra operational log on pending user affinity (event 1097)",
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"collectorType": "eventlog",
"target": "Microsoft-Windows-AAD/Operational",
"parameters": {
"eventId": "1097",
"maxEntries": "20"
},
"trigger": "on_event",
"triggerEventType": "entra_user_affinity_pending",
"outputEventType": "gather_aad_operational_events",
"outputSeverity": "Warning",
"tags": ["device", "entra", "hybrid"]
}
+1 -1
View File
@@ -168,7 +168,7 @@
},
"source": {
"type": "string",
"description": "Source of the signal.",
"description": "Source of the signal. phase_duration and app_install_duration never derive a duration from a span with mixed timestamp provenance (exactly one IME-log endpoint with sourceOffsetOrigin=reader-zone-fallback, i.e. the writer timezone was unknown to the agent for one endpoint only): such a condition evaluates false with evidence timestampProvenance=reader-zone-fallback. Two fallback endpoints share the same error and stay measurable.",
"enum": ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "app_install_duration", "event_correlation", "clock_skew"]
},
"skewMetric": {
@@ -34,6 +34,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
new Regex("\\[\"eventType\"\\]\\s*=\\s*\"([a-z][a-z0-9_]*)\"", RegexOptions.Compiled),
};
// Multi-line callback shape (2026-09-04, D-072 gap): `OnTraceEvent?.Invoke(` at the end
// of one line, the event-type literal alone on the NEXT line. The single-line patterns
// above never saw `desktop_real_user_detected` / `desktop_excluded_user` this way.
private static readonly Regex InvokeOpener = new Regex("\\.Invoke\\(\\s*$", RegexOptions.Compiled);
private static readonly Regex BareLiteralLine = new Regex("^\\s*\"([a-z][a-z0-9_]*)\"\\s*,?\\s*$", RegexOptions.Compiled);
// The reducer/param key name itself ("eventType") and the like are not event types.
private static readonly HashSet<string> NotEventTypes = new(StringComparer.Ordinal)
{
@@ -59,33 +65,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
Assert.True(Directory.Exists(full), $"V2 source dir not found: {full}");
foreach (var file in EnumerateSourceFiles(full))
{
var lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmed = line.TrimStart();
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
continue;
if (line.Contains("const ")) continue;
foreach (var pattern in EmitPatterns)
{
foreach (Match m in pattern.Matches(line))
{
var literal = m.Groups[1].Value;
if (NotEventTypes.Contains(literal)) continue;
if (!known.Contains(literal))
{
offenders.Add(
$"{Path.GetFileName(file)}:{i + 1} \"{literal}\" -> {line.Trim()}");
}
}
}
}
}
offenders.AddRange(FindOffenders(Path.GetFileName(file), File.ReadAllLines(file), known));
}
Assert.True(
@@ -95,6 +75,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
string.Join("\n ", offenders));
}
[Fact]
public void Guard_detects_multiline_Invoke_literal()
{
// Mutation proof for the multi-line shape: a synthetic emit site with an unknown
// literal on the line after `OnTraceEvent?.Invoke(` must be reported; the same
// shape with a known type must not.
var known = new HashSet<string>(StringComparer.Ordinal) { "desktop_arrived" };
string[] bad =
{
" OnTraceEvent?.Invoke(",
" \"desktop_not_a_real_type\",",
" $\"Real user desktop detected\",",
};
string[] good =
{
" OnTraceEvent?.Invoke(",
" \"desktop_arrived\",",
};
var offender = Assert.Single(FindOffenders("X.cs", bad, known));
Assert.Contains("desktop_not_a_real_type", offender);
Assert.Empty(FindOffenders("X.cs", good, known));
}
private static List<string> FindOffenders(string fileName, string[] lines, HashSet<string> known)
{
var offenders = new List<string>();
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmed = line.TrimStart();
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
continue;
if (line.Contains("const ")) continue;
foreach (var pattern in EmitPatterns)
{
foreach (Match m in pattern.Matches(line))
Check(m.Groups[1].Value, line, i);
}
if (i > 0 && InvokeOpener.IsMatch(lines[i - 1]))
{
var bare = BareLiteralLine.Match(line);
if (bare.Success) Check(bare.Groups[1].Value, line, i);
}
}
return offenders;
void Check(string literal, string line, int index)
{
if (NotEventTypes.Contains(literal)) return;
if (!known.Contains(literal))
offenders.Add($"{fileName}:{index + 1} \"{literal}\" -> {line.Trim()}");
}
}
/// <summary>All string-const values declared on <see cref="Constants.EventTypes"/>.</summary>
private static HashSet<string> KnownEventTypes()
{
@@ -113,18 +152,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
private static string RepoRoot()
{
var dir = AppContext.BaseDirectory;
for (int i = 0; i < 12; i++)
{
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
var parent = Directory.GetParent(dir)?.FullName;
if (parent == null || parent == dir) break;
dir = parent;
}
throw new DirectoryNotFoundException(
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
}
private static string RepoRoot() => Harness.RepoPaths.Root();
}
}
@@ -0,0 +1,27 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
/// <summary>Repository paths for tests that read or write checked-in files (guards, fixtures).</summary>
internal static class RepoPaths
{
/// <summary>Walks up from the test binary to the directory holding <c>AutopilotMonitor.sln</c>.</summary>
public static string Root()
{
var dir = AppContext.BaseDirectory;
for (int i = 0; i < 12; i++)
{
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
var parent = Directory.GetParent(dir)?.FullName;
if (parent == null || parent == dir) break;
dir = parent;
}
throw new DirectoryNotFoundException(
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
}
/// <summary><c>tests/fixtures/&lt;name&gt;</c> under the repo root.</summary>
public static string Fixtures(string name) => Path.Combine(Root(), "tests", "fixtures", name);
}
}
@@ -13,6 +13,7 @@ using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
{
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
using AutopilotMonitor.Shared.Models;
#pragma warning disable xUnit1031 // SpinWait.SpinUntil for uploader-batch-arrival assertion
@@ -0,0 +1,236 @@
#nullable enable
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
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.DecisionCore.Signals;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// Entra user affinity after the real-user desktop (2026-09-04, session a7140f98). The
/// contracts that matter: arm only on Hybrid devices and only at the desktop; the first
/// IME user-token line AFTER the desktop cancels and posts <c>ime_user_token_acquired</c>;
/// token lines from before the desktop (device phase) are ignored; the timer posts
/// <c>entra_user_affinity_pending</c> with the failure codes seen since the desktop; both
/// emissions are single-shot per process.
/// </summary>
public sealed class EntraUserAffinityDetectorTests
{
private static readonly DateTime Fixed = new DateTime(2026, 9, 4, 13, 54, 6, DateTimeKind.Utc);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
public InformationalEventPost Post { get; }
public bool Hybrid { get; set; } = true;
public bool PlaceholderActive { get; set; } = true;
public Fixture()
{
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Post = new InformationalEventPost(Ingress, Clock);
}
public EntraUserAffinityDetector Build() => new EntraUserAffinityDetector(
Post, Logger,
isHybridJoinProbe: () => Hybrid,
placeholderActiveProbe: () => PlaceholderActive,
delay: TimeSpan.FromMinutes(10),
utcNow: () => Clock.UtcNow);
public void Dispose() => Tmp.Dispose();
}
private static FakeSignalIngressSink.PostedSignal? Find(FakeSignalIngressSink ingress, string eventType) =>
ingress.Posted.FirstOrDefault(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType);
[Fact]
public void Desktop_on_hybrid_device_arms()
{
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
Assert.True(d.IsArmedForTest);
}
[Fact]
public void Desktop_on_non_hybrid_device_does_not_arm()
{
using var f = new Fixture { Hybrid = false };
using var d = f.Build();
d.NotifyRealUserDesktop();
d.TriggerFromTest();
Assert.False(d.IsArmedForTest);
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
}
[Fact]
public void Timer_without_token_emits_pending_with_failure_codes()
{
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(1));
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(3));
d.NotifyTokenFailureLine("3400073247", f.Clock.UtcNow.AddMinutes(5));
f.Clock.Advance(TimeSpan.FromMinutes(10));
d.TriggerFromTest();
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
Assert.NotNull(pending);
Assert.Equal("Warning", pending!.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", pending.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("EntraUserAffinityDetector", pending.Payload[SignalPayloadKeys.Source]);
Assert.Equal("10", pending.Payload["delayMinutes"]);
Assert.Equal("timer_fired", pending.Payload["reason"]);
Assert.Equal("3", pending.Payload["tokenFailureCount"]);
Assert.Equal("3399548929,3400073247", pending.Payload["tokenFailureCodes"]);
Assert.Equal("true", pending.Payload["placeholderActive"]);
Assert.Equal("10.0", pending.Payload["minutesSinceDesktop"]);
Assert.True(d.HasFiredForTest);
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
}
[Fact]
public void Token_after_desktop_cancels_and_posts_token_acquired()
{
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddSeconds(30));
var tokenAt = f.Clock.UtcNow.AddMinutes(3.7);
d.NotifyUserTokenAcquired(tokenAt);
f.Clock.Advance(TimeSpan.FromMinutes(10));
d.TriggerFromTest();
var acquired = Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired);
Assert.NotNull(acquired);
Assert.Equal("Info", acquired!.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("3.7", acquired.Payload["minutesAfterDesktop"]);
Assert.Equal("1", acquired.Payload["tokenFailuresBeforeSuccess"]);
Assert.Equal(tokenAt, acquired.OccurredAtUtc);
Assert.True(d.TokenAcquiredPostedForTest);
Assert.False(d.HasFiredForTest);
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
}
[Fact]
public void Token_lines_from_before_the_desktop_are_ignored()
{
// Device-phase token lines ("Failed to get AAD token" is routine there; a success
// can be the device check-in) must neither count nor cancel — the 2-min tolerance
// only covers the desktop poll interval.
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(-20));
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(-15));
f.Clock.Advance(TimeSpan.FromMinutes(10));
d.TriggerFromTest();
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
Assert.NotNull(pending);
Assert.Equal("0", pending!.Payload!["tokenFailureCount"]);
Assert.Equal(string.Empty, pending.Payload["tokenFailureCodes"]);
}
[Fact]
public void Token_within_desktop_poll_tolerance_counts_as_after_desktop()
{
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddSeconds(-90));
Assert.NotNull(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
Assert.Equal("0.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
}
[Fact]
public void Token_without_arm_is_ignored()
{
using var f = new Fixture();
using var d = f.Build();
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
}
[Fact]
public void Pending_fires_once_and_a_late_token_still_resolves_it_once()
{
// A token that arrives AFTER the warning is the most useful thing the timeline can
// show ("affinity came late") and lets the backend rule drop the finding — so it is
// posted, but like the warning only once per process.
using var f = new Fixture();
using var d = f.Build();
d.NotifyRealUserDesktop();
d.NotifyRealUserDesktop();
f.Clock.Advance(TimeSpan.FromMinutes(10));
d.TriggerFromTest();
d.TriggerFromTest();
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(1));
Assert.Single(f.Ingress.Posted, p =>
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.EntraUserAffinityPending);
Assert.Single(f.Ingress.Posted, p =>
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.ImeUserTokenAcquired);
Assert.Equal("10.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
}
[Fact]
public void Hybrid_probe_throwing_does_not_arm()
{
using var f = new Fixture();
using var d = new EntraUserAffinityDetector(
f.Post, f.Logger,
isHybridJoinProbe: () => throw new InvalidOperationException("registry"),
utcNow: () => f.Clock.UtcNow);
d.NotifyRealUserDesktop();
Assert.False(d.IsArmedForTest);
}
[Fact]
public void Dispose_after_arm_does_not_throw_and_blocks_emission()
{
using var f = new Fixture();
var d = f.Build();
d.NotifyRealUserDesktop();
d.Dispose();
d.TriggerFromTest();
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
}
}
}
@@ -73,6 +73,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.Equal("true", info.Payload![SignalPayloadKeys.ImmediateUpload]);
}
[Theory]
[InlineData(304, "join phase")]
[InlineData(305, "authentication phase")]
public void ProcessHelloEvent_304_305_emit_device_registration_event_with_immediate_upload(int eventId, string phaseLabel)
{
// Hybrid affinity companion (2026-09-04): UDR 304/305 ride the already-armed
// watcher and surface as device_registration_event — a Warning, flushed at once,
// and never a Hello completion.
using var f = new Fixture();
f.Tracker.ProcessHelloEvent(eventId, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
var info = f.InfoEvent("device_registration_event");
Assert.Equal("Warning", info.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Contains(phaseLabel, info.Payload[SignalPayloadKeys.Message]);
Assert.DoesNotContain(f.Ingress.Posted, p =>
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et.StartsWith("hello_provisioning"));
}
[Fact]
public void ProcessHelloEvent_358_willlaunch_is_suppressed()
{
@@ -194,6 +194,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.NotNull(FindHybridLoginPending(f.Ingress));
}
// ============================================================================
// Real-user desktop cancel path (2026-09-04, session a7140f98)
// ============================================================================
[Fact]
public void Real_user_desktop_after_Arm_cancels_emission()
{
using var f = new Fixture();
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
detector.Arm();
detector.NotifyRealUserDesktop();
detector.TriggerFromTest();
Assert.True(detector.IsCancelledByDesktopForTest);
Assert.False(detector.HasFiredForTest);
Assert.Null(FindHybridLoginPending(f.Ingress));
}
[Fact]
public void Real_user_desktop_before_Arm_short_circuits_Arm()
{
// The DAD first poll runs seconds after start; the arm request comes from the
// runtime host slightly later. A desktop seen first must make the arm a no-op.
using var f = new Fixture();
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
detector.NotifyRealUserDesktop();
detector.Arm();
detector.TriggerFromTest();
Assert.False(detector.IsArmedForTest);
Assert.Null(FindHybridLoginPending(f.Ingress));
}
[Fact]
public void Emission_reports_desktop_absence_and_placeholder_as_fact()
{
using var f = new Fixture();
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
detector.Arm();
detector.TriggerFromTest();
var info = FindHybridLoginPending(f.Ingress);
Assert.NotNull(info);
Assert.Equal("false", info!.Payload!["realUserDesktopSeen"]);
Assert.Equal("false", info.Payload["placeholderActive"]); // watcher never saw JoinInfo in this fixture
Assert.Contains("no real-user desktop", info.Payload[SignalPayloadKeys.Message]);
Assert.DoesNotContain("placeholder still active", info.Payload[SignalPayloadKeys.Message]);
}
// ============================================================================
// Lifecycle / Dispose
// ============================================================================
@@ -0,0 +1,215 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Backlog era anchoring (2026-09-04, session a7140f98). The property under test: the
/// bootstrap execution record — stdout in AgentExecutor.log, policy result in the service
/// log — measured against the install marker anchors exactly the era it sits in; a SKIP
/// re-run, a missing marker, an MSI bootstrap and an off-grid residual anchor nothing; the
/// anchor never crosses an "EMS Agent Started" boundary; sibling files join by local time
/// only while the service eras are unambiguous.
/// </summary>
public sealed class ImeLogEraPreScanTests
{
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
{
var local = utcInstant + writerOffset;
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
}
private static string ScriptStart(DateTime utc, TimeSpan offset) =>
Entry($@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\00000000-0000-0000-0000-000000000000_{PolicyId}.ps1", utc, offset);
private static string BootstrapStdout(DateTime utc, TimeSpan offset, string tail = "===== Bootstrap Completed Successfully =====") =>
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\nBootstrap script version: v2.0\nAgent install mode completed successfully\n" + tail + ", error = ", utc, offset);
private static string PolicyResult(DateTime utc, TimeSpan offset, string policyId = PolicyId) =>
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {policyId}, policy result = Success", utc, offset);
private static string ServiceStarted(DateTime utc, TimeSpan offset) => Entry("EMS Agent Started", utc, offset);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
private readonly List<ImeLogEraPreScan.ScanInput> _inputs = new List<ImeLogEraPreScan.ScanInput>();
public string Write(string fileName, params string[] entries)
{
var path = Path.Combine(Tmp.Path, fileName);
File.WriteAllText(path, string.Join(Environment.NewLine, entries) + Environment.NewLine);
_inputs.Add(new ImeLogEraPreScan.ScanInput(path, 0));
return path;
}
public long OffsetOfLine(string fileName, int lineIndex)
{
// Entries are written line by line; the byte offset of entry N is the sum of the
// preceding lines (CRLF-agnostic through Environment.NewLine).
var path = Path.Combine(Tmp.Path, fileName);
var lines = File.ReadAllLines(path);
long offset = 0;
for (var i = 0; i < lineIndex; i++)
offset += System.Text.Encoding.UTF8.GetByteCount(lines[i]) + Environment.NewLine.Length;
return offset;
}
public CmTraceEraTable Build(DateTime? deployed) =>
ImeLogEraPreScan.Build(_inputs, deployed, ImeLogTracker.MaxEntryBytes, null);
public void Dispose() => Tmp.Dispose();
}
[Fact]
public void Bootstrap_record_anchors_service_era_and_executor_block_only_within_their_boundaries()
{
using var f = new Fixture();
f.Write("AgentExecutor.log",
ScriptStart(Deployed.AddSeconds(-20), Pacific),
BootstrapStdout(Deployed.AddMilliseconds(200), Pacific),
Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(5), Pacific),
Entry("write output done. output = other script, error = ", Deployed.AddMinutes(5).AddSeconds(2), Pacific));
f.Write("IntuneManagementExtension.log",
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(-1), Pacific), // line 0, era 0
PolicyResult(Deployed.AddMilliseconds(260), Pacific), // line 1, anchor
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(10), Pacific), // line 2, era 0
ServiceStarted(Deployed.AddMinutes(20), Eastern), // line 3, era 1 (post-boot, new zone)
Entry("Successfully get the token", Deployed.AddMinutes(21), Eastern)); // line 4, era 1
var table = f.Build(Deployed);
Assert.Equal(2, table.AnchoredEraCount);
Assert.False(table.TransferAmbiguous);
// Service era 0: every offset before "EMS Agent Started" resolves to the Pacific offset.
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 0), out var off0, out var kind0));
Assert.Equal(Pacific, off0);
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, kind0);
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 2), out var off2, out _));
Assert.Equal(Pacific, off2);
// Era 1 has no anchor of its own — the Pacific anchor must not leak across the boundary.
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 3), out _, out _));
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 4), out _, out _));
// Executor: only the bootstrap execution block is anchored, not the next script's block.
Assert.True(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 0), out var offX, out var kindX));
Assert.Equal(Pacific, offX);
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, kindX);
Assert.False(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 5), out _, out _));
// Transfer for in-process sibling files: a local time inside era 0's range resolves,
// one inside era 1's range does not.
Assert.True(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + Pacific, out var offT, out var kindT));
Assert.Equal(Pacific, offT);
Assert.EndsWith("/service-era-transfer", kindT);
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(25) + Eastern, out _, out _));
}
[Fact]
public void Without_deployed_marker_nothing_is_anchored()
{
using var f = new Fixture();
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
var table = f.Build(null);
Assert.Equal(0, table.AnchoredEraCount);
}
[Fact]
public void Msi_bootstrap_without_stdout_record_anchors_nothing()
{
// The MSI runner never passes through AgentExecutor; without the policy id the
// service log's result lines cannot be told apart from any other script's.
using var f = new Fixture();
f.Write("AgentExecutor.log", Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(3), Pacific));
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
var table = f.Build(Deployed);
Assert.Equal(0, table.AnchoredEraCount);
}
[Fact]
public void Skip_rerun_record_is_never_offered_and_first_record_off_grid_anchors_nothing()
{
// The install run is the FIRST bootstrap record; a SKIP re-run 15 minutes later
// would round onto the grid with zero residual and anchor a wrong offset.
using var f = new Fixture();
f.Write("AgentExecutor.log",
ScriptStart(Deployed.AddMinutes(-9), Pacific),
BootstrapStdout(Deployed.AddMinutes(-8), Pacific), // first record: residual 8 min → rejected
ScriptStart(Deployed.AddMinutes(15).AddSeconds(-2), Pacific),
BootstrapStdout(Deployed.AddMinutes(15), Pacific)); // would be a perfect (wrong) grid hit
f.Write("IntuneManagementExtension.log",
PolicyResult(Deployed.AddMinutes(-8), Pacific),
PolicyResult(Deployed.AddMinutes(15), Pacific));
var table = f.Build(Deployed);
Assert.Equal(0, table.AnchoredEraCount);
}
[Fact]
public void Service_era_continues_across_a_log_rotation()
{
using var f = new Fixture();
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
f.Write("IntuneManagementExtension-20260904-133000.log",
PolicyResult(Deployed.AddMilliseconds(260), Pacific),
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(2), Pacific));
f.Write("IntuneManagementExtension.log",
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(12), Pacific), // same process, rotated file
ServiceStarted(Deployed.AddMinutes(20), Eastern));
var table = f.Build(Deployed);
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", 0, out var off, out _));
Assert.Equal(Pacific, off);
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out _, out _));
}
[Fact]
public void Overlapping_service_era_local_ranges_disable_the_transfer()
{
// A westward zone change between two service starts makes era 1 start "before"
// era 0 in local time; the sibling-file transfer must refuse rather than guess.
using var f = new Fixture();
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), TimeSpan.FromHours(2)), BootstrapStdout(Deployed.AddMilliseconds(200), TimeSpan.FromHours(2)));
f.Write("IntuneManagementExtension.log",
ServiceStarted(Deployed.AddMinutes(-30), TimeSpan.FromHours(2)),
PolicyResult(Deployed.AddMilliseconds(260), TimeSpan.FromHours(2)),
ServiceStarted(Deployed.AddMinutes(20), TimeSpan.FromHours(-7)));
var table = f.Build(Deployed);
Assert.True(table.TransferAmbiguous);
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + TimeSpan.FromHours(2), out _, out _));
// Offset-based lookup is unaffected.
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out var off, out _));
Assert.Equal(TimeSpan.FromHours(2), off);
}
[Fact]
public void Deployment_marker_parses_roundtrip_utc()
{
Assert.Equal(Deployed, AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("2026-09-04T13:31:49.1000000Z"));
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse(""));
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("not a date"));
}
}
}
@@ -0,0 +1,171 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// End-to-end through the tracker: a restart/first-sight backlog whose pre-reboot era was
/// written under the OOBE default zone (a7140f98: Pacific) while the agent believes the
/// zone Windows set later (Eastern). With the bootstrap record anchoring that era, every
/// pre-reboot line resolves to its true instant with origin <c>era-anchored</c>; the
/// post-reboot era without an anchor keeps the marked fallback; in-process sibling files
/// join by local time; a fresh pass still self-anchors per line.
/// </summary>
public sealed class ImeLogTrackerEraAnchoringTests
{
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "T-ALL", Category = "always", Enabled = true,
Pattern = @"marker (?<n>\d+)",
Action = "noop",
Parameters = new Dictionary<string, string>(),
},
};
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
{
var local = utcInstant + writerOffset;
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
}
private sealed class Harness : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public ImeLogTracker Tracker { get; }
public DateTime Now { get; set; } = Deployed.AddMinutes(30);
public List<(string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)> Resolved { get; } =
new List<(string, DateTime?, CmTraceOffsetOrigin, int?, string)>();
public Harness()
{
Tracker = new ImeLogTracker(
logFolder: _tmp.Path,
patterns: Patterns(),
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info));
Tracker.UtcNowProvider = () => Now;
Tracker.DeployedUtcProvider = () => Deployed;
Tracker.OnPatternMatched += _ => Resolved.Add((
Tracker.LastMatchedPatternId ?? string.Empty,
Tracker.LastMatchedLogTimestamp,
Tracker.LastMatchedSourceOffsetOrigin,
Tracker.LastMatchedSourceOffsetMinutes,
Tracker.LastMatchedEraAnchorKind));
}
public void Append(string fileName, params string[] lines)
=> File.AppendAllText(Path.Combine(_tmp.Path, fileName), string.Join(Environment.NewLine, lines) + Environment.NewLine);
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
public void Dispose()
{
Tracker.Dispose();
_tmp.Dispose();
}
}
[Fact]
public async Task Backlog_lines_of_the_bootstrap_era_resolve_through_the_anchor_and_the_next_era_stays_on_fallback()
{
using var h = new Harness();
h.Append("AgentExecutor.log",
Entry($@"Adding argument powershell with value C:\x\Policies\Scripts\u_{PolicyId}.ps1", Deployed.AddSeconds(-20), Pacific),
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\n===== Bootstrap Completed Successfully =====, error = ", Deployed.AddMilliseconds(200), Pacific),
Entry("marker 10", Deployed.AddSeconds(1), Pacific));
h.Append("IntuneManagementExtension.log",
Entry("marker 1", Deployed.AddMinutes(-1), Pacific),
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {PolicyId}, policy result = Success", Deployed.AddMilliseconds(260), Pacific),
Entry("marker 2", Deployed.AddMinutes(10), Pacific),
Entry("EMS Agent Started", Deployed.AddMinutes(20), Eastern),
Entry("marker 3", Deployed.AddMinutes(21), Eastern));
h.Append("AppWorkload.log",
Entry("marker 4", Deployed.AddMinutes(5), Pacific),
Entry("marker 5", Deployed.AddMinutes(25), Eastern));
h.Tracker.PreScanEras();
await h.Pass(); // first sight: backlog, never fresh
var byMarker = new Dictionary<string, (string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)>();
foreach (var r in h.Resolved) byMarker[r.Message] = r;
Assert.Equal(6, h.Resolved.Count);
// Marker text is the pattern id (T-ALL) — index by resolution order instead.
var r1 = h.Resolved[0]; // AgentExecutor marker 10 (files sort: AgentExecutor < AppWorkload < IntuneManagementExtension)
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r1.Origin);
Assert.Equal(Deployed.AddSeconds(1), r1.Ts);
Assert.Equal(-420, r1.Offset);
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, r1.Anchor);
var r4 = h.Resolved[1]; // AppWorkload marker 4 — transfer by local time into the service era
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r4.Origin);
Assert.Equal(Deployed.AddMinutes(5), r4.Ts);
Assert.EndsWith("/service-era-transfer", r4.Anchor);
var r5 = h.Resolved[2]; // AppWorkload marker 5 — post-boot era, unanchored
Assert.Equal(CmTraceOffsetOrigin.None, r5.Origin);
Assert.Equal(string.Empty, r5.Anchor);
var rm1 = h.Resolved[3]; // IME marker 1
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm1.Origin);
Assert.Equal(Deployed.AddMinutes(-1), rm1.Ts);
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, rm1.Anchor);
var rm2 = h.Resolved[4]; // IME marker 2
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm2.Origin);
Assert.Equal(Deployed.AddMinutes(10), rm2.Ts);
var rm3 = h.Resolved[5]; // IME marker 3 — after "EMS Agent Started", no anchor
Assert.Equal(CmTraceOffsetOrigin.None, rm3.Origin);
}
[Fact]
public async Task Fresh_lines_still_self_anchor_after_the_pre_scan()
{
using var h = new Harness();
h.Append("IntuneManagementExtension.log", Entry("marker 1", h.Now, Eastern));
h.Tracker.PreScanEras();
await h.Pass();
h.Now = h.Now.AddSeconds(10);
var writtenAt = h.Now.AddMilliseconds(-80);
h.Append("IntuneManagementExtension.log", Entry("marker 2", writtenAt, Eastern));
await h.Pass();
var last = h.Resolved[h.Resolved.Count - 1];
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, last.Origin);
Assert.Equal(writtenAt, last.Ts);
Assert.Equal(string.Empty, last.Anchor);
}
[Fact]
public async Task Without_marker_the_backlog_keeps_the_reader_zone_fallback()
{
using var h = new Harness();
h.Tracker.DeployedUtcProvider = () => null;
h.Append("IntuneManagementExtension.log", Entry("marker 1", Deployed.AddMinutes(-1), Pacific));
h.Tracker.PreScanEras();
await h.Pass();
Assert.Single(h.Resolved);
Assert.Equal(CmTraceOffsetOrigin.None, h.Resolved[0].Origin);
Assert.Equal(0, h.Tracker.EraTable.AnchoredEraCount);
}
}
}
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Raw per-line token callbacks for the Entra user-affinity detector (2026-09-04).
/// <c>OnUserTokenAcquired</c> fires on EVERY IME-TOKEN-SUCCESS line — independent of the
/// phase-change dedup that swallows the second AccountSetup detection — and
/// <c>OnTokenFailureLine</c> on every failure line; both honour the historic-replay gate.
/// </summary>
public sealed class ImeLogTrackerTokenLineCallbackTests
{
private static readonly DateTime Now = new DateTime(2026, 9, 4, 14, 0, 0, DateTimeKind.Utc);
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "IME-TOKEN-SUCCESS", Category = "always", Enabled = true,
Pattern = "^Successfully get the token",
Action = "espPhaseDetected",
Parameters = new Dictionary<string, string> { ["phase"] = "AccountSetup" },
},
new ImeLogPattern
{
PatternId = "IME-TOKEN-FAILURE", Category = "always", Enabled = true,
Pattern = @"^Failed to get AAD token\..*?errorCode = (?<errorCode>.*?)$",
Action = "imeTokenFailure",
Parameters = new Dictionary<string, string>(),
},
};
private static ImeLogTracker Build(TempDirectory tmp)
{
var tracker = new ImeLogTracker(
logFolder: tmp.Path,
patterns: Patterns(),
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
tracker.UtcNowProvider = () => Now;
return tracker;
}
private const string Success = "Successfully get the token with client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f";
private const string Failure = "Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929";
[Fact]
public void Every_token_success_line_invokes_callback_even_when_phase_is_deduplicated()
{
using var tmp = new TempDirectory();
using var tracker = Build(tmp);
var tokens = new List<DateTime?>();
tracker.OnUserTokenAcquired = ts => tokens.Add(ts);
// The phase-change dedup lives in the adapter (AccountSetup is emitted once);
// the raw token callback must fire for BOTH lines regardless.
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-6));
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-2));
Assert.Equal(2, tokens.Count);
Assert.Equal(Now.AddMinutes(-6), tokens[0]);
Assert.Equal(Now.AddMinutes(-2), tokens[1]);
}
[Fact]
public void Every_failure_line_invokes_callback_with_error_code()
{
using var tmp = new TempDirectory();
using var tracker = Build(tmp);
var failures = new List<(string code, DateTime? ts)>();
tracker.OnTokenFailureLine = (code, ts) => failures.Add((code, ts));
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-5));
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-4));
Assert.Equal(2, failures.Count);
Assert.All(failures, f => Assert.Equal("3399548929", f.code));
Assert.Equal(Now.AddMinutes(-5), failures[0].ts);
}
[Fact]
public void Historic_replay_lines_do_not_invoke_either_callback()
{
using var tmp = new TempDirectory();
using var tracker = Build(tmp);
var tokens = 0;
var failures = 0;
tracker.OnUserTokenAcquired = _ => tokens++;
tracker.OnTokenFailureLine = (_, __) => failures++;
tracker.ProcessLogMessageForTest(Success, Now.AddDays(-3));
tracker.ProcessLogMessageForTest(Failure, Now.AddDays(-3));
Assert.Equal(0, tokens);
Assert.Equal(0, failures);
}
[Fact]
public void Callback_exception_does_not_break_the_drain()
{
using var tmp = new TempDirectory();
using var tracker = Build(tmp);
tracker.OnUserTokenAcquired = _ => throw new InvalidOperationException("boom");
tracker.OnTokenFailureLine = (_, __) => throw new InvalidOperationException("boom");
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-1));
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-1));
}
}
}
@@ -11,6 +11,7 @@ using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
using AutopilotMonitor.Shared.Models;
#pragma warning disable xUnit1031
@@ -42,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// items must be a whiteglove_resumed event.
Assert.DoesNotContain(
rig.Uploader.Received.SelectMany(b => b),
item => item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
item => item.Kind == TelemetryItemKind.Event &&
item.PayloadJson != null &&
item.PayloadJson.Contains("\"whiteglove_resumed\""));
}
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var resumedEvents = rig.Uploader.Received
.SelectMany(b => b)
.Where(item =>
item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
item.Kind == TelemetryItemKind.Event &&
item.PayloadJson != null &&
item.PayloadJson.Contains("\"whiteglove_resumed\""))
.ToList();
@@ -15,6 +15,7 @@ using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
using AutopilotMonitor.Shared.Models;
#pragma warning disable xUnit1031
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
using AutopilotMonitor.Shared.Models;
#pragma warning disable xUnit1031
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Signals;
using Newtonsoft.Json.Linq;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Signals
{
@@ -8,6 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.State;
using Newtonsoft.Json.Linq;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Transitions
{
@@ -6,6 +6,7 @@ using System.Net.Http;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Newtonsoft.Json.Linq;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
@@ -4,6 +4,7 @@ using System.Text;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Xunit;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
@@ -0,0 +1,250 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals;
using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>
/// The agent → backend wire contract of <c>POST /api/agent/telemetry</c>, proven from the
/// WRITER side. <see cref="TelemetryItemDto"/> (Shared) is the one class both ends serialise;
/// <see cref="TelemetryItem.ToWire"/> is the only mapping onto it.
/// <list type="bullet">
/// <item>Round trip + reflection: every DTO property is populated by <c>ToWire()</c> and
/// survives Newtonsoft defaults — a DTO field added without a mapping fails here.</item>
/// <item>Frozen body: the real batch serialiser's output for one item per
/// <see cref="TelemetryItemKind"/> is pinned under <c>tests/fixtures/telemetry-wire/</c>.
/// The backend's <c>TelemetryWireFixtureTests</c> reads the same files and demands zero
/// rejections, so a rename on either side breaks a test until both sides and the fixture
/// agree. Regenerate with <c>AM_WRITE_WIRE_FIXTURES=1</c>.</item>
/// <item>Poison body: the exact 422 shape the backend writes (pinned there by
/// <c>TelemetryWireFixtureTests</c>) is parsed as an item-level poison signal here.</item>
/// </list>
/// </summary>
public sealed class TelemetryWireContractTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private const string FixtureFile = "agent-v2.json";
private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
private static TelemetryItem FullyPopulatedItem() =>
new TelemetryItem(
kind: TelemetryItemKind.DecisionTransition,
partitionKey: $"{TenantId}_{SessionId}",
rowKey: "0000000007",
telemetryItemId: 42,
sessionTraceOrdinal: 7,
payloadJson: "{\"StepIndex\":7}",
requiresImmediateFlush: true,
enqueuedAtUtc: At,
retryCount: 3);
// ============================================================ ToWire mapping
[Fact]
public void ToWire_round_trips_every_property_through_Newtonsoft_defaults()
{
var item = FullyPopulatedItem();
var json = JsonConvert.SerializeObject(item.ToWire());
var dto = JsonConvert.DeserializeObject<TelemetryItemDto>(json)!;
Assert.Equal("DecisionTransition", dto.Kind);
Assert.Equal(item.PartitionKey, dto.PartitionKey);
Assert.Equal(item.RowKey, dto.RowKey);
Assert.Equal(item.TelemetryItemId, dto.TelemetryItemId);
Assert.Equal(item.SessionTraceOrdinal, dto.SessionTraceOrdinal);
Assert.Equal(item.PayloadJson, dto.PayloadJson);
Assert.Equal(item.RequiresImmediateFlush, dto.RequiresImmediateFlush);
Assert.Equal(item.EnqueuedAtUtc, dto.EnqueuedAtUtc);
Assert.Equal(DateTimeKind.Utc, dto.EnqueuedAtUtc.Kind);
Assert.Equal(item.RetryCount, dto.RetryCount);
}
[Fact]
public void ToWire_populates_every_TelemetryItemDto_property()
{
// A DTO property that ToWire() does not set keeps its default — this catches a field
// added to the wire type without a mapping (the backend would read the default forever).
var dto = FullyPopulatedItem().ToWire();
var unset = typeof(TelemetryItemDto)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => IsDefault(p.GetValue(dto), p.PropertyType))
.Select(p => p.Name)
.ToList();
Assert.True(unset.Count == 0,
"TelemetryItem.ToWire() leaves these TelemetryItemDto properties at their default: " + string.Join(", ", unset));
}
[Fact]
public void TelemetryItemDto_properties_mirror_TelemetryItem()
{
var itemProps = typeof(TelemetryItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p.PropertyType);
foreach (var dtoProp in typeof(TelemetryItemDto).GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
Assert.True(itemProps.TryGetValue(dtoProp.Name, out var itemType),
$"TelemetryItemDto.{dtoProp.Name} has no counterpart on TelemetryItem");
var expected = dtoProp.Name == nameof(TelemetryItemDto.Kind) ? typeof(TelemetryItemKind) : dtoProp.PropertyType;
Assert.True(itemType == expected,
$"TelemetryItemDto.{dtoProp.Name} is {dtoProp.PropertyType.Name}, TelemetryItem.{dtoProp.Name} is {itemType.Name}");
}
}
[Fact]
public void Every_kind_serialises_by_name_on_the_wire_and_on_the_spool_line()
{
foreach (var kind in TelemetryItemKinds.All)
{
var item = new TelemetryItem(kind, "T_S", "rk", 1, 1, "{}", false, At);
var wire = JObject.Parse(JsonConvert.SerializeObject(item.ToWire()));
Assert.Equal(kind.ToString(), (string?)wire["Kind"]);
// The spool line (TelemetryItemSerializer) has always written the NAME too; the enum's
// move to Shared must not turn it into an ordinal (a crash-restart rehydrates it).
var spool = JObject.Parse(TelemetryItemSerializer.Serialize(item));
Assert.Equal(kind.ToString(), (string?)spool["Kind"]);
Assert.Equal(kind, TelemetryItemSerializer.Deserialize(spool.ToString(Formatting.None)).Kind);
Assert.True(TelemetryItemKinds.TryParse(kind.ToString(), out var parsed));
Assert.Equal(kind, parsed);
}
}
// ============================================================ Frozen wire body
[Fact]
public void Frozen_wire_body_matches_the_current_serializer()
{
var items = OneItemPerKind();
Assert.Equal(TelemetryItemKinds.All.OrderBy(k => k), items.Select(i => i.Kind).Distinct().OrderBy(k => k));
var body = BackendTelemetryUploader.SerializeBatch(items);
var path = Path.Combine(RepoPaths.Fixtures("telemetry-wire"), FixtureFile);
if (Environment.GetEnvironmentVariable("AM_WRITE_WIRE_FIXTURES") == "1")
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, body + "\n", new UTF8Encoding(false));
}
Assert.True(File.Exists(path), "Missing wire fixture " + path + " — run this test with AM_WRITE_WIRE_FIXTURES=1 to create it.");
var frozen = File.ReadAllText(path).TrimEnd('\r', '\n');
Assert.True(string.Equals(frozen, body, StringComparison.Ordinal),
"The agent's telemetry wire body changed. If the change is intended, update the backend ingest " +
"(TelemetryWireFixtureTests must still dispatch every frozen body) and regenerate with " +
"AM_WRITE_WIRE_FIXTURES=1.\nExpected: " + frozen + "\nActual: " + body);
}
/// <summary>
/// One item per kind through the REAL emitters (payload shapes are theirs, not hand-typed),
/// re-based onto fixed transport fields so the body is byte-stable.
/// </summary>
private static IReadOnlyList<TelemetryItem> OneItemPerKind()
{
using var tmp = new TempDirectory();
var transport = new FakeTelemetryTransport();
var counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
new TelemetryEventEmitter(transport, counter, SessionId, TenantId).Emit(new EnrollmentEvent
{
EventId = "00000000-0000-4000-8000-000000000001",
EventType = Constants.EventTypes.AgentStarted,
Severity = EventSeverity.Info,
Source = "Agent",
Timestamp = At,
Phase = EnrollmentPhase.Unknown,
Message = "Agent started",
Data = new Dictionary<string, object> { ["agentVersion"] = "2.0.0" },
});
new TelemetrySignalEmitter(transport, SessionId, TenantId).Emit(new DecisionSignal(
sessionSignalOrdinal: 1,
sessionTraceOrdinal: 1,
kind: DecisionSignalKind.SessionStarted,
kindSchemaVersion: 1,
occurredAtUtc: At,
sourceOrigin: "Fixture",
evidence: new Evidence(EvidenceKind.Raw, "raw-1", "session start")));
new TelemetryTransitionEmitter(transport, SessionId, TenantId).Emit(new DecisionTransition(
stepIndex: 1,
sessionTraceOrdinal: 2,
signalOrdinalRef: 1,
occurredAtUtc: At,
trigger: "SessionStarted",
fromStage: SessionStage.Unknown,
toStage: SessionStage.AwaitingEspPhaseChange,
taken: true,
deadEndReason: null,
reducerVersion: "1.0.0"));
return transport.Enqueued
.Select(e => new TelemetryItem(e.Kind, e.PartitionKey, e.RowKey, e.TelemetryItemId, e.SessionTraceOrdinal,
e.PayloadJson, e.RequiresImmediateFlush, At))
.ToList();
}
// ============================================================ Poison body
/// <summary>
/// The exact 422 body the backend writes for rejected items (pinned byte-for-byte in the
/// backend's TelemetryWireFixtureTests). camelCase because the backend serialises with
/// its wire options; the agent's parser accepts both casings.
/// </summary>
internal const string BackendPoisonBody =
"{\"error\":\"2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.\"," +
"\"code\":\"TelemetryItemsRejected\",\"correlationId\":\"cid-1\",\"poison\":true,\"rejectedRowKeys\":[\"rk-1\",\"rk-3\"]," +
"\"reason\":\"unknown_kind=1;unparseable=1\",\"received\":3,\"rejected\":2}";
[Fact]
public async Task Backend_422_poison_body_is_parsed_as_item_level_poison()
{
using var handler = new RecordingHttpMessageHandler().QueueStatus((HttpStatusCode)422, BackendPoisonBody);
using var http = new HttpClient(handler);
var sut = new BackendTelemetryUploader(http, baseUrl: "https://backend.example.invalid", tenantId: TenantId, agentVersion: "2.0.0");
var result = await sut.UploadBatchAsync(new[] { FullyPopulatedItem() }, CancellationToken.None);
Assert.False(result.Success);
Assert.True(result.IsPoison);
Assert.Equal(new[] { "rk-1", "rk-3" }, result.PoisonRowKeys);
Assert.Equal("unknown_kind=1;unparseable=1", result.ErrorReason);
}
private static bool IsDefault(object? value, Type type)
{
if (value == null) return true;
if (type == typeof(string)) return ((string)value).Length == 0;
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return underlying.IsValueType && value.Equals(Activator.CreateInstance(underlying));
}
}
}
@@ -0,0 +1,157 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// A writer era: the stretch of log written by ONE process lifetime, i.e. under one
/// timezone belief. For the IME service family (IntuneManagementExtension*.log) an era
/// starts at an "EMS Agent Started" entry and may continue across a log rotation into the
/// next file; for AgentExecutor.log every script execution is its own short-lived process
/// and therefore its own era. An era carries an offset only when an anchor inside it was
/// matched against an instant the agent knows in UTC (<see cref="ImeLogEraPreScan"/>).
/// </summary>
internal sealed class CmTraceEra
{
/// <summary>File segments (file name → [start, end) byte range) that belong to this era, in write order.</summary>
public List<CmTraceEraSegment> Segments { get; } = new List<CmTraceEraSegment>();
/// <summary>Local time of the era's first entry as written (null for a leading era whose start predates the scanned region).</summary>
public DateTime? StartLocal { get; set; }
/// <summary>Local time of the next era's first entry (null for the last era).</summary>
public DateTime? EndLocal { get; set; }
/// <summary>The writer's UTC offset (local = UTC + offset) when anchored.</summary>
public TimeSpan? Offset { get; set; }
/// <summary>What established <see cref="Offset"/> ("bootstrap-policy-result", "bootstrap-stdout", …).</summary>
public string? AnchorKind { get; set; }
public bool IsAnchored => Offset.HasValue;
}
internal sealed class CmTraceEraSegment
{
public CmTraceEraSegment(string fileName, long startOffset, long endOffset)
{
FileName = fileName;
StartOffset = startOffset;
EndOffset = endOffset;
}
public string FileName { get; }
public long StartOffset { get; }
/// <summary><c>long.MaxValue</c> while the era is still open in this file.</summary>
public long EndOffset { get; set; }
public bool Contains(string fileName, long offset) =>
string.Equals(FileName, fileName, StringComparison.OrdinalIgnoreCase)
&& offset >= StartOffset && offset < EndOffset;
}
/// <summary>
/// Era lookup for backlog lines (2026-09-04, session a7140f98). Two lookups:
/// <list type="bullet">
/// <item><description><see cref="TryResolveByOffset"/> — the line's own file and byte offset
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
/// decompiled build). Within one process every file shares the zone belief, so a local
/// timestamp places the line in the service era whose local range contains it. Disabled
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
/// </list>
/// Everything not resolvable here stays on the marked reader-zone fallback; a partially
/// corrected era is never produced because an anchor applies to a whole era or not at all.
/// </summary>
internal sealed class CmTraceEraTable
{
private readonly List<CmTraceEra> _offsetEras = new List<CmTraceEra>();
private readonly List<CmTraceEra> _serviceEras = new List<CmTraceEra>();
private bool _transferAmbiguous;
public IReadOnlyList<CmTraceEra> ServiceEras => _serviceEras;
public bool TransferAmbiguous => _transferAmbiguous;
public int AnchoredEraCount
{
get
{
var n = 0;
foreach (var e in _offsetEras) if (e.IsAnchored) n++;
return n;
}
}
/// <summary>Register an era addressable by (file, offset). Service eras also take part in the local-time transfer.</summary>
public void Add(CmTraceEra era, bool isServiceEra)
{
_offsetEras.Add(era);
if (isServiceEra) _serviceEras.Add(era);
}
/// <summary>Call once after all service eras are added: validates the local ranges used by the transfer.</summary>
public void Seal()
{
_transferAmbiguous = false;
DateTime? previousStart = null;
foreach (var era in _serviceEras)
{
if (!era.StartLocal.HasValue) continue;
if (previousStart.HasValue && era.StartLocal.Value <= previousStart.Value)
{
_transferAmbiguous = true;
break;
}
previousStart = era.StartLocal;
}
}
public bool TryResolveByOffset(string fileName, long offset, out TimeSpan writerOffset, out string anchorKind)
{
writerOffset = TimeSpan.Zero;
anchorKind = string.Empty;
if (string.IsNullOrEmpty(fileName) || offset < 0) return false;
foreach (var era in _offsetEras)
{
if (!era.IsAnchored) continue;
foreach (var seg in era.Segments)
{
if (seg.Contains(fileName, offset))
{
writerOffset = era.Offset!.Value;
anchorKind = era.AnchorKind ?? string.Empty;
return true;
}
}
}
return false;
}
public bool TryResolveByLocalTime(DateTime localTimestamp, out TimeSpan writerOffset, out string anchorKind)
{
writerOffset = TimeSpan.Zero;
anchorKind = string.Empty;
if (_transferAmbiguous) return false;
var local = DateTime.SpecifyKind(localTimestamp, DateTimeKind.Unspecified);
foreach (var era in _serviceEras)
{
if (!era.IsAnchored) continue;
// A leading era with unknown start covers everything before its end.
var afterStart = !era.StartLocal.HasValue || local >= era.StartLocal.Value;
var beforeEnd = !era.EndLocal.HasValue || local < era.EndLocal.Value;
if (afterStart && beforeEnd)
{
writerOffset = era.Offset!.Value;
anchorKind = era.AnchorKind + "/service-era-transfer";
return true;
}
}
return false;
}
}
}
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>.
/// </summary>
LineAnchored = 3,
/// <summary>
/// A backlog line resolved through its writer ERA: the stretch of log written by one
/// process lifetime, anchored by an entry inside that era whose UTC instant the agent
/// knows exactly (the install marker vs. the IME's own record of the bootstrap script's
/// result — a few hundred milliseconds apart). An anchor applies to a whole era or not
/// at all, never across an "EMS Agent Started" boundary. See <c>ImeLogEraPreScan</c>
/// (2026-09-04, session a7140f98).
/// </summary>
EraAnchored = 4,
}
/// <summary>
@@ -0,0 +1,316 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Builds the <see cref="CmTraceEraTable"/> for the backlog the tracker is about to read
/// (2026-09-04, session a7140f98: the IME service wrote the whole device-ESP era under the
/// OOBE default zone, Windows moved the zone before the agent started, and the restart
/// replay put every pre-reboot line three hours early — a 20-minute DeviceSetup became a
/// "blocking app timeout").
/// <para>
/// Backlog lines can never self-anchor (their age would round onto the offset grid), so an
/// era needs an entry whose UTC instant is known independently. The one such entry every
/// platform-script bootstrap leaves behind is the bootstrap script's own execution: the
/// install marker (<c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, UTC) is written
/// milliseconds before the script exits, and the IME logs that exit twice — AgentExecutor.log
/// records the script's stdout ("Bootstrap Completed Successfully"), and the service log
/// records "[PowerShell] … Policy id = &lt;bootstrap&gt;, policy result = …". Field measurement
/// (4 sessions, 2026-09-04): 0.13–0.28 s between marker and stdout record, 0.02–0.08 s more to
/// the service record — against a 15-minute grid with a 2-minute residual guard.
/// </para>
/// <para>
/// Rules that keep this sound: only the FIRST bootstrap record of each file is offered (a
/// SKIP re-run minutes later could round onto the grid); the residual guard is
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>; an anchor applies to the whole
/// era between two "EMS Agent Started" entries (service) or one script execution
/// (AgentExecutor) and never beyond; nothing is anchored without a marker (MSI bootstrap,
/// replay tooling). Everything else stays on the marked reader-zone fallback.
/// </para>
/// </summary>
internal static class ImeLogEraPreScan
{
internal const string AnchorKindBootstrapStdout = "bootstrap-stdout";
internal const string AnchorKindBootstrapPolicyResult = "bootstrap-policy-result";
private const string ServiceStartedPrefix = "EMS Agent Started";
private const string ExecutorScriptStartPrefix = "Adding argument powershell with value ";
private const string ExecutorOutputPrefix = "write output done.";
private const string BootstrapInstallRunMarker = "Bootstrap Completed Successfully";
private const string BootstrapInstallRunMarkerAlt = "Agent install mode completed successfully";
private static readonly Regex ExecutorScriptPolicyId = new Regex(
@"Policies\\Scripts\\[a-z0-9\-]+_(?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.ps1",
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
private static readonly Regex ServicePolicyResult = new Regex(
@"^\[PowerShell\] User Id = [a-f0-9\-]+, Policy id = (?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}), policy result = ",
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
private const int MaxEntryLines = 100;
public sealed class ScanInput
{
public ScanInput(string filePath, long startOffset)
{
FilePath = filePath;
StartOffset = startOffset;
}
public string FilePath { get; }
public long StartOffset { get; }
}
public static bool IsServiceLogFile(string fileName) =>
fileName.StartsWith("IntuneManagementExtension", StringComparison.OrdinalIgnoreCase)
|| fileName.StartsWith("_IntuneManagementExtension", StringComparison.OrdinalIgnoreCase);
public static bool IsExecutorLogFile(string fileName) =>
fileName.StartsWith("AgentExecutor", StringComparison.OrdinalIgnoreCase);
/// <summary>Files written in-process by the IME service without an era marker of their own (transfer by local time).</summary>
public static bool IsServiceHostedFile(string fileName) =>
fileName.StartsWith("AppWorkload", StringComparison.OrdinalIgnoreCase)
|| fileName.StartsWith("AppActionProcessor", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Scan the given files (sort order = write order; archived before current) from their
/// start offsets to EOF and return the era table. Never throws; a file that cannot be
/// read contributes no eras.
/// </summary>
public static CmTraceEraTable Build(
IReadOnlyList<ScanInput> files,
DateTime? deployedUtc,
int maxEntryBytes,
AgentLogger? logger)
{
var table = new CmTraceEraTable();
if (!deployedUtc.HasValue)
{
logger?.Info("ImeLogEraPreScan: no Deployed marker — backlog eras stay on the reader-zone fallback");
return table;
}
string? bootstrapPolicyId = null;
// AgentExecutor first: it is where the bootstrap policy id becomes known.
foreach (var input in files)
{
if (!IsExecutorLogFile(Path.GetFileName(input.FilePath))) continue;
try { ScanExecutorFile(input, deployedUtc.Value, maxEntryBytes, table, ref bootstrapPolicyId, logger); }
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: {Path.GetFileName(input.FilePath)} skipped: {ex.Message}"); }
}
var serviceFiles = new List<ScanInput>();
foreach (var input in files)
if (IsServiceLogFile(Path.GetFileName(input.FilePath))) serviceFiles.Add(input);
try { ScanServiceFiles(serviceFiles, deployedUtc.Value, maxEntryBytes, table, bootstrapPolicyId, logger); }
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: service scan aborted: {ex.Message}"); }
table.Seal();
logger?.Info(
$"ImeLogEraPreScan: {table.AnchoredEraCount} anchored era(s), {table.ServiceEras.Count} service era(s), " +
$"bootstrapPolicyId={(bootstrapPolicyId ?? "(unknown)")}, transfer={(table.TransferAmbiguous ? "ambiguous-disabled" : "enabled")}");
return table;
}
private static void ScanExecutorFile(
ScanInput input, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
ref string? bootstrapPolicyId, AgentLogger? logger)
{
var fileName = Path.GetFileName(input.FilePath);
CmTraceEra? current = null;
string? currentPolicyId = null;
var anchored = false;
foreach (var e in ReadEntries(input, maxEntryBytes))
{
if (e.Message.StartsWith(ExecutorScriptStartPrefix, StringComparison.Ordinal))
{
if (current != null) current.Segments[0].EndOffset = e.Offset;
current = new CmTraceEra();
current.Segments.Add(new CmTraceEraSegment(fileName, e.Offset, long.MaxValue));
current.StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null;
table.Add(current, isServiceEra: false);
var m = ExecutorScriptPolicyId.Match(e.Message);
currentPolicyId = m.Success ? m.Groups["id"].Value : null;
continue;
}
if (anchored || current == null || !e.HasTimestamp) continue;
if (!e.Message.StartsWith(ExecutorOutputPrefix, StringComparison.Ordinal)) continue;
if (e.Message.IndexOf(BootstrapInstallRunMarker, StringComparison.OrdinalIgnoreCase) < 0
&& e.Message.IndexOf(BootstrapInstallRunMarkerAlt, StringComparison.OrdinalIgnoreCase) < 0)
continue;
// First bootstrap install record of this file, offered once — accepted or not.
anchored = true;
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
{
current.Offset = offset;
current.AnchorKind = AnchorKindBootstrapStdout;
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap execution anchored at {offset} (stdout local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, policy={currentPolicyId ?? "?"})");
}
else
{
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap stdout record rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
}
}
}
private static void ScanServiceFiles(
IReadOnlyList<ScanInput> files, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
string? bootstrapPolicyId, AgentLogger? logger)
{
if (files.Count == 0) return;
// The leading era: whatever process was writing when the scanned region begins.
var current = new CmTraceEra();
table.Add(current, isServiceEra: true);
var resultOffered = false;
foreach (var input in files)
{
var fileName = Path.GetFileName(input.FilePath);
var segment = new CmTraceEraSegment(fileName, input.StartOffset, long.MaxValue);
current.Segments.Add(segment);
foreach (var e in ReadEntries(input, maxEntryBytes))
{
if (e.Message.StartsWith(ServiceStartedPrefix, StringComparison.Ordinal))
{
segment.EndOffset = e.Offset;
var next = new CmTraceEra { StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null };
current.EndLocal = next.StartLocal;
segment = new CmTraceEraSegment(fileName, e.Offset, long.MaxValue);
next.Segments.Add(segment);
table.Add(next, isServiceEra: true);
current = next;
continue;
}
if (resultOffered || bootstrapPolicyId == null || !e.HasTimestamp) continue;
if (!e.Message.StartsWith("[PowerShell] User Id = ", StringComparison.Ordinal)) continue;
var m = ServicePolicyResult.Match(e.Message);
if (!m.Success || !string.Equals(m.Groups["id"].Value, bootstrapPolicyId, StringComparison.OrdinalIgnoreCase)) continue;
// First result record for the bootstrap policy, offered once.
resultOffered = true;
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
{
current.Offset = offset;
current.AnchorKind = AnchorKindBootstrapPolicyResult;
logger?.Info($"ImeLogEraPreScan: {fileName} service era anchored at {offset} (policy result local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff})");
}
else
{
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap policy result rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
}
}
// The era continues into the next (newer) file: close this file's segment at EOF.
try { segment.EndOffset = new FileInfo(input.FilePath).Length; } catch { segment.EndOffset = long.MaxValue; }
}
// The last era stays open in its last file.
var last = current.Segments[current.Segments.Count - 1];
last.EndOffset = long.MaxValue;
}
private sealed class ScannedEntry
{
public long Offset;
public bool HasTimestamp;
public DateTime LocalTimestamp;
public string Message = string.Empty;
}
/// <summary>
/// Enumerate CMTrace entries with their file offsets. Same multiline assembly as the
/// tracker's read loop (an AgentExecutor stdout record spans many lines; the closing tag
/// carries the time); oversized or over-long entries are dropped, never partially used.
/// </summary>
private static IEnumerable<ScannedEntry> ReadEntries(ScanInput input, int maxEntryBytes)
{
using (var stream = new FileStream(input.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
if (input.StartOffset > 0 && input.StartOffset < stream.Length)
stream.Seek(input.StartOffset, SeekOrigin.Begin);
else if (input.StartOffset >= stream.Length)
yield break;
var reader = new BoundedLineReader(stream, maxEntryBytes);
StringBuilder? buffer = null;
var bufferLines = 0;
long entryStart = input.StartOffset;
var skipping = false;
while (true)
{
var line = reader.ReadLineAsync(System.Threading.CancellationToken.None).GetAwaiter().GetResult();
if (line == null) yield break;
if (buffer == null) entryStart = reader.LastLineStart;
if (reader.LastLineTruncated)
{
buffer = null; bufferLines = 0; skipping = true;
continue;
}
if (skipping)
{
if (line.StartsWith("<![LOG[", StringComparison.Ordinal)) skipping = false;
else
{
if (line.Contains("]LOG]!>")) skipping = false;
continue;
}
}
if (buffer != null)
{
buffer.Append('\n').Append(line);
bufferLines++;
if (line.Contains("]LOG]!>"))
{
line = buffer.ToString();
buffer = null; bufferLines = 0;
}
else if (bufferLines >= MaxEntryLines || buffer.Length >= maxEntryBytes)
{
buffer = null; bufferLines = 0; skipping = true;
continue;
}
else continue;
}
else if (line.StartsWith("<![LOG[", StringComparison.Ordinal) && !line.Contains("]LOG]!>"))
{
buffer = new StringBuilder(line);
bufferLines = 1;
continue;
}
if (!CmTraceLogParser.TryParseLine(line, out var entry) || entry == null) continue;
yield return new ScannedEntry
{
Offset = entryStart,
HasTimestamp = entry.HasTimestamp && !entry.BiasMinutes.HasValue,
LocalTimestamp = entry.LocalTimestamp,
Message = entry.Message ?? string.Empty,
};
}
}
}
}
}
@@ -263,6 +263,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// --- Normal processing (single-line or completed multiline) ---
CmTraceLogEntry entry;
string messageToMatch;
_currentEntryOffset = entryStart;
if (CmTraceLogParser.TryParseLine(line, out entry))
{
messageToMatch = entry.Message;
@@ -312,6 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// flagged as such.
if (hadPreviousObservation && calibrationAnchor != null)
CalibrateFrom(_currentSourceFileName, Path.GetFileName(filePath), calibrationAnchor);
_currentEntryOffset = -1;
}
}
catch (FileNotFoundException) { }
@@ -509,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
LastMatchedSourceOffsetMinutes = null;
LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.None;
LastMatchedMeasuredWriterOffsetMinutes = null;
LastMatchedEraAnchorKind = string.Empty;
}
else
{
@@ -518,6 +521,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
LastMatchedSourceLocalTimestamp = entry.HasTimestamp ? entry.LocalTimestamp : (DateTime?)null;
LastMatchedSourceOffsetMinutes = offsetMinutes;
LastMatchedSourceOffsetOrigin = origin;
LastMatchedEraAnchorKind = origin == CmTraceOffsetOrigin.EraAnchored ? _lastEraAnchorKind : string.Empty;
// Observational: what the calibrator measured for this file, distinct from what
// was applied above.
@@ -555,7 +559,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Placed after the staleness gate so a success replayed from a previous
// enrollment's log cannot clear a genuine current outage.
if (!isStaleReplayLine && string.Equals(pattern.PatternId, "IME-TOKEN-SUCCESS", StringComparison.OrdinalIgnoreCase))
{
ClearPendingTokenFailure();
// Raw user-token observation for the Entra user-affinity detector (2026-09-04).
// Deliberately NOT routed through espPhaseDetected: that path deduplicates once
// AccountSetup is set, so a FullESP session would never report the token that
// followed the real-user desktop.
try { OnUserTokenAcquired?.Invoke(LastMatchedLogTimestamp); }
catch (Exception ex) { _logger?.Warning($"OnUserTokenAcquired handler threw: {ex.Message}"); }
}
try
{
@@ -617,6 +629,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
case "imetokenfailure":
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
// Raw per-line observation (staleness already enforced above via
// AppMutatingActions) — the affinity detector counts failures after
// the real-user desktop; the grace-window Warning stays separate.
try { OnTokenFailureLine?.Invoke(match.Groups["errorCode"]?.Value, LastMatchedLogTimestamp); }
catch (Exception ex) { _logger?.Warning($"OnTokenFailureLine handler threw: {ex.Message}"); }
break;
case "imeimpersonation":
@@ -941,6 +958,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
// ERA ANCHOR (2026-09-04, session a7140f98): a backlog line whose writer era was
// anchored by the bootstrap execution record resolves through that era's offset.
// Whole era or nothing — the anchor never crosses an "EMS Agent Started" boundary,
// and in-process sibling files (AppWorkload, AppActionProcessor) join by local
// time only while the service eras' local ranges are unambiguous. See
// ImeLogEraPreScan for why this is the one anchor a backlog line can trust.
_lastEraAnchorKind = string.Empty;
if (_currentSourceFileName != null && _currentEntryOffset >= 0)
{
TimeSpan eraOffset;
string anchorKind;
var resolved = EraTable.TryResolveByOffset(_currentSourceFileName, _currentEntryOffset, out eraOffset, out anchorKind)
|| (ImeLogEraPreScan.IsServiceHostedFile(_currentSourceFileName)
&& EraTable.TryResolveByLocalTime(entry.LocalTimestamp, out eraOffset, out anchorKind));
if (resolved)
{
origin = CmTraceOffsetOrigin.EraAnchored;
offsetMinutes = (int)eraOffset.TotalMinutes;
_lastEraAnchorKind = anchorKind;
return DateTime.SpecifyKind(entry.LocalTimestamp - eraOffset, DateTimeKind.Utc);
}
}
// Fallback for everything not provably fresh: assume the reader's zone, UNIFORMLY.
// Wrong in absolute terms whenever the writer held a different belief, but
// self-consistent — both ends of a duration are wrong by the same amount, so derived
@@ -400,6 +400,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// <summary>How that offset was arrived at — declared, measured, or neither.</summary>
public CmTraceOffsetOrigin LastMatchedSourceOffsetOrigin { get; internal set; }
/// <summary>
/// For <see cref="CmTraceOffsetOrigin.EraAnchored"/>: which anchor established the era's
/// offset ("bootstrap-policy-result", "bootstrap-stdout", with "/service-era-transfer"
/// when the line came from an in-process sibling file). Empty otherwise.
/// </summary>
public string LastMatchedEraAnchorKind { get; internal set; } = string.Empty;
/// <summary>
/// Source of the install marker's UTC instant for the backlog era pre-scan
/// (<see cref="ImeLogEraPreScan"/>). Defaults to the registry marker; tests inject.
/// </summary>
internal Func<DateTime?> DeployedUtcProvider { get; set; } = Security.DeploymentMarker.TryReadDeployedUtc;
/// <summary>Era table built by <see cref="PreScanEras"/> at <see cref="Start"/>; empty until then.</summary>
internal CmTraceEraTable EraTable { get; private set; } = new CmTraceEraTable();
// Byte offset of the entry currently being resolved in the read loop (-1 outside it) —
// the key for the era lookup. Set next to _currentSourceFileName.
private long _currentEntryOffset = -1;
private string _lastEraAnchorKind = string.Empty;
/// <summary>
/// The offset the calibrator MEASURED for the source file, which is not necessarily the
/// one applied.
@@ -425,6 +446,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
// Fired once per distinct errorCode per tracker lifetime (see HandleImeTokenFailure).
public Action<string, string> OnImeTokenFailure { get; set; }
/// <summary>
/// Fires on EVERY IME-TOKEN-SUCCESS line that passes the historic-replay gate, with the
/// line's resolved UTC (null when the line carried no timestamp). Unlike
/// <see cref="OnEspPhaseChanged"/> (deduplicated once AccountSetup is set) and unlike the
/// grace-window model behind <see cref="OnImeTokenFailure"/>, this is the raw observation:
/// "Successfully get the token" is IntuneTokenManager acquiring the signed-in user's token
/// through WAM, i.e. the only SYSTEM-visible proof of Entra user affinity (2026-09-04).
/// </summary>
public Action<DateTime?> OnUserTokenAcquired { get; set; }
/// <summary>
/// Fires on EVERY IME-TOKEN-FAILURE line that passes the historic-replay gate:
/// errorCode (may be empty) and the line's resolved UTC. Raw observation — the
/// deduplicated Warning stays on <see cref="OnImeTokenFailure"/>.
/// </summary>
public Action<string, DateTime?> OnTokenFailureLine { get; set; }
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
// log line, null when the DO collector observed the completion live via polling. The
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
@@ -839,6 +875,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
}
/// <summary>
/// Build the backlog era table from the files the first pass will read, each from the
/// position the pass will start at. Never throws — a failure leaves the table empty and
/// every backlog line on the reader-zone fallback, exactly as before.
/// </summary>
internal void PreScanEras()
{
try
{
if (!Directory.Exists(_logFolder)) return;
var inputs = new List<ImeLogEraPreScan.ScanInput>();
var files = new List<string>();
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
files.Sort(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files)
{
var info = new FileInfo(filePath);
if (!info.Exists) continue;
inputs.Add(new ImeLogEraPreScan.ScanInput(filePath, _positionTracker.GetSafePosition(filePath, info.Length)));
}
DateTime? deployedUtc = null;
try { deployedUtc = DeployedUtcProvider?.Invoke(); }
catch (Exception ex) { _logger.Debug($"ImeLogTracker: Deployed marker read failed: {ex.Message}"); }
EraTable = ImeLogEraPreScan.Build(inputs, deployedUtc, MaxEntryBytes, _logger);
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: era pre-scan failed — backlog stays on the reader-zone fallback: {ex.Message}");
EraTable = new CmTraceEraTable();
}
}
/// <summary>
/// Starts the background polling task
/// </summary>
@@ -851,6 +925,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// exactly where we left off — no re-parsing, no device-phase bleeding.
LoadState();
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
// before the poll loop so every replayed line already finds its era; read-only on
// the position tracker. Replay tooling anchors nothing — its logs did not come from
// this device's bootstrap.
if (!SimulationMode) PreScanEras();
_cts = new CancellationTokenSource();
var token = _cts.Token;
@@ -67,6 +67,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public event EventHandler<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
public event EventHandler<AadUserJoinedEventArgs> AadUserJoined;
/// <summary>
/// <c>true</c> while a provisioning placeholder (foouser@/autopilot@) is the JoinInfo
/// user and no real user has replaced it. On a Hybrid join this stays true through the
/// whole user sign-in — the AD sign-in never touches JoinInfo — so consumers report it
/// as a fact, never as sign-in evidence (2026-09-04).
/// </summary>
internal bool PlaceholderObservedWithoutRealUser
{
get { lock (_stateLock) return _placeholderFired && !_realUserFired; }
}
public AadJoinWatcher(AgentLogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -234,7 +234,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
try
{
OnTraceEvent?.Invoke(
"desktop_excluded_user",
SharedEventTypes.DesktopExcludedUser,
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
}
@@ -250,7 +250,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
try
{
OnTraceEvent?.Invoke(
"desktop_real_user_detected",
SharedEventTypes.DesktopRealUserDetected,
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
}
@@ -0,0 +1,232 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
using SharedConstants = AutopilotMonitor.Shared.Constants;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Single-shot detector for Entra user affinity on Hybrid Azure AD Join devices
/// (2026-09-04, session a7140f98). After the real user signs in, IME acquires that user's
/// token through WAM and logs "Successfully get the token" (IntuneTokenManager,
/// <c>IME-TOKEN-SUCCESS</c>). That line is the only SYSTEM-visible proof that the user
/// session carries an Entra identity (PRT); a session whose user never gets one shows only
/// "Failed to get AAD token" lines after the desktop.
/// <para>
/// Contract: <see cref="NotifyRealUserDesktop"/> arms the timer when the device is a Hybrid
/// join (probe injected by the host). The first token success stamped after the desktop
/// cancels the timer and emits <c>ime_user_token_acquired</c>; the timer emits
/// <c>entra_user_affinity_pending</c> with the failure codes observed since the desktop.
/// A token that arrives after the timer fired is still posted once — it tells the
/// timeline (and the backend rule) that affinity came late rather than never.
/// Both emissions happen at most once per agent process — a reboot starts a new process,
/// whose repeat is exactly the confidence signal the backend rule counts. Token lines older
/// than the desktop (minus a small tolerance for the 30 s desktop poll) belong to the
/// device phase and are ignored: they appear in every enrollment.
/// </para>
/// <para>
/// Not a decision signal. Never influences completion. The JoinInfo placeholder is reported
/// as a fact in the payload because it is NOT a login signal on a Hybrid join — the AD
/// sign-in never replaces it; only the later Entra device registration does.
/// </para>
/// </summary>
internal sealed class EntraUserAffinityDetector : IDisposable
{
internal const int DefaultDelayMinutes = 10;
internal const string SourceLabel = "EntraUserAffinityDetector";
/// <summary>Token lines this much older than the desktop observation still count as post-desktop.</summary>
internal static readonly TimeSpan DesktopPollTolerance = TimeSpan.FromMinutes(2);
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly Func<bool> _isHybridJoinProbe;
private readonly Func<bool>? _placeholderActiveProbe;
private readonly Func<DateTime> _utcNow;
private readonly TimeSpan _delay;
private readonly object _lock = new object();
private readonly List<string> _failureCodesSinceDesktop = new List<string>();
private Timer? _timer;
private DateTime? _desktopObservedUtc;
private bool _armed;
private bool _fired;
private bool _tokenAcquiredPosted;
private int _failureCountSinceDesktop;
private int _disposed;
internal EntraUserAffinityDetector(
InformationalEventPost post,
AgentLogger logger,
Func<bool> isHybridJoinProbe,
Func<bool>? placeholderActiveProbe = null,
TimeSpan? delay = null,
Func<DateTime>? utcNow = null)
{
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_isHybridJoinProbe = isHybridJoinProbe ?? throw new ArgumentNullException(nameof(isHybridJoinProbe));
_placeholderActiveProbe = placeholderActiveProbe;
_delay = delay ?? TimeSpan.FromMinutes(DefaultDelayMinutes);
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
/// <summary>
/// The DesktopArrivalDetector resolved explorer.exe under a real (non-placeholder,
/// non-system) user. Arms the timer on Hybrid devices; idempotent; no-op after a
/// token success or after the timer fired.
/// </summary>
public void NotifyRealUserDesktop()
{
bool hybrid;
try { hybrid = _isHybridJoinProbe(); }
catch (Exception ex)
{
_logger.Warning($"EntraUserAffinityDetector: hybrid probe threw — not arming: {ex.Message}");
return;
}
lock (_lock)
{
if (_disposed != 0 || _armed || _fired || _tokenAcquiredPosted) return;
if (!hybrid)
{
_logger.Debug("EntraUserAffinityDetector: real-user desktop on a non-Hybrid device — not armed");
return;
}
_desktopObservedUtc = _utcNow();
_armed = true;
_logger.Info(
$"EntraUserAffinityDetector: armed at real-user desktop — will emit {SharedConstants.EventTypes.EntraUserAffinityPending} in {_delay.TotalMinutes:F0} min unless IME acquires a user token");
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
}
}
/// <summary>IME-TOKEN-SUCCESS line observed; <paramref name="lineUtc"/> is the line's resolved time.</summary>
public void NotifyUserTokenAcquired(DateTime? lineUtc)
{
DateTime desktop;
double minutesAfterDesktop;
lock (_lock)
{
if (_disposed != 0 || !_armed || _tokenAcquiredPosted) return;
desktop = _desktopObservedUtc!.Value;
if (!IsAfterDesktop(lineUtc, desktop)) return;
_tokenAcquiredPosted = true;
_timer?.Dispose();
_timer = null;
minutesAfterDesktop = ((lineUtc ?? _utcNow()) - desktop).TotalMinutes;
}
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["minutesAfterDesktop"] = Math.Max(0, minutesAfterDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
["tokenFailuresBeforeSuccess"] = _failureCountSinceDesktop.ToString(System.Globalization.CultureInfo.InvariantCulture),
["isHybridJoin"] = "true",
};
_post.Emit(
eventType: SharedConstants.EventTypes.ImeUserTokenAcquired,
source: SourceLabel,
message: "Entra user token acquired by IME after the real-user desktop — user affinity established",
severity: EventSeverity.Info,
immediateUpload: false,
data: data,
occurredAtUtc: lineUtc);
_logger.Info($"EntraUserAffinityDetector: user token observed {minutesAfterDesktop:F1} min after desktop — timer cancelled");
}
/// <summary>IME-TOKEN-FAILURE line observed; counted only when stamped after the desktop.</summary>
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
{
lock (_lock)
{
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
if (!IsAfterDesktop(lineUtc, _desktopObservedUtc!.Value)) return;
_failureCountSinceDesktop++;
var code = string.IsNullOrWhiteSpace(errorCode) ? "unknown" : errorCode!.Trim();
if (!_failureCodesSinceDesktop.Contains(code, StringComparer.OrdinalIgnoreCase))
_failureCodesSinceDesktop.Add(code);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
}
// Test seams — same lock and state guards as the production paths.
internal void TriggerFromTest() => OnTimer(null);
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
internal bool TokenAcquiredPostedForTest { get { lock (_lock) { return _tokenAcquiredPosted; } } }
private static bool IsAfterDesktop(DateTime? lineUtc, DateTime desktopUtc)
{
// A line without a timestamp cannot be placed — treat it as current (the tracker
// only hands us lines it just read), which is after the desktop by construction.
if (!lineUtc.HasValue) return true;
return lineUtc.Value >= desktopUtc - DesktopPollTolerance;
}
private void OnTimer(object? state)
{
string codes;
int count;
double minutesSinceDesktop;
lock (_lock)
{
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
_fired = true;
_timer?.Dispose();
_timer = null;
codes = string.Join(",", _failureCodesSinceDesktop);
count = _failureCountSinceDesktop;
minutesSinceDesktop = (_utcNow() - _desktopObservedUtc!.Value).TotalMinutes;
}
var placeholderActive = "unknown";
try
{
if (_placeholderActiveProbe != null)
placeholderActive = _placeholderActiveProbe() ? "true" : "false";
}
catch { /* observational */ }
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(System.Globalization.CultureInfo.InvariantCulture),
["reason"] = "timer_fired",
["minutesSinceDesktop"] = Math.Max(0, minutesSinceDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
["tokenFailureCount"] = count.ToString(System.Globalization.CultureInfo.InvariantCulture),
["tokenFailureCodes"] = codes,
["placeholderActive"] = placeholderActive,
["isHybridJoin"] = "true",
};
_post.Emit(
eventType: SharedConstants.EventTypes.EntraUserAffinityPending,
source: SourceLabel,
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after the real-user desktop IME has not acquired an Entra user token — user affinity pending"
+ (count > 0 ? $" ({count} token failure(s): {codes})" : string.Empty),
severity: EventSeverity.Warning,
immediateUpload: true,
data: data);
_logger.Warning($"EntraUserAffinityDetector: emitted {SharedConstants.EventTypes.EntraUserAffinityPending} (failures={count}, codes={codes})");
}
}
}
@@ -36,6 +36,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal const int EventId_ProvisioningWillNotLaunch = 360;
internal const int EventId_ProvisioningBlocked = 362;
internal const int EventId_PinStatus = 376;
// Automatic device registration failed at the join / authentication phase (Hybrid Join
// affinity diagnosis, 2026-09-04). Not Hello events — they ride the same UDR watcher
// because the channel is already armed; surfaced as device_registration_event.
internal const int EventId_AutoRegistrationFailedJoin = 304;
internal const int EventId_AutoRegistrationFailedAuth = 305;
internal const int EventId_HelloForBusiness_ProcessingStarted = 3024;
internal const int EventId_HelloForBusiness_ProcessingStopped = 6045;
@@ -47,6 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
{
EventId_AutoRegistrationFailedJoin,
EventId_AutoRegistrationFailedAuth,
EventId_NgcKeyRegistered,
EventId_NgcKeyRegistrationFailed,
EventId_ProvisioningWillLaunch,
@@ -547,7 +554,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var query = new EventLogQuery(
UdrEventLogChannel,
PathType.LogName,
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
"*[System[(EventID=300 or EventID=301 or EventID=304 or EventID=305 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
_udrWatcher = new EventLogWatcher(query);
_udrWatcher.EventRecordWritten += OnUdrEventRecordWritten;
@@ -654,6 +661,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
message = "Windows Hello PIN status update";
break;
case EventId_AutoRegistrationFailedJoin: // 304
case EventId_AutoRegistrationFailedAuth: // 305
// OS-side record that the automatic device registration failed (join or
// authentication phase). Companion evidence for entra_user_affinity_pending —
// no Hello state involved, so no MarkHelloCompleted().
eventType = Constants.EventTypes.DeviceRegistrationEvent;
severity = EventSeverity.Warning;
message = $"User Device Registration: automatic registration failed (event {eventId}, "
+ (eventId == EventId_AutoRegistrationFailedJoin ? "join phase)" : "authentication phase)");
_logger.Info($"User Device Registration failure event {eventId} observed");
break;
default:
return;
}
@@ -691,6 +710,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// flip state multiple times — keep batched.
ImmediateUpload = shouldTriggerHelloCompleted
|| eventType == Constants.EventTypes.HelloProvisioningFailed
|| eventType == Constants.EventTypes.DeviceRegistrationEvent
});
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
@@ -10,19 +10,27 @@ using SharedConstants = AutopilotMonitor.Shared.Constants;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Single-shot detector for the Hybrid User-Driven completion-gap (2026-05-01 trigger:
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After a Hybrid AAD-Join reboot the
/// agent restarts and waits for the user to log in with their real AD/AAD account
/// (which replaces the foouser/autopilot placeholder in JoinInfo). When that login is
/// overdue the agent today goes silent until the backend 5-h watchdog fires; this
/// detector emits a <c>hybrid_login_pending</c> warning event after a short single-shot
/// timer so the operator sees an explicit "stuck waiting for AD login" signal in the
/// timeline.
/// Single-shot detector for the Hybrid User-Driven sign-in gap (2026-05-01 trigger:
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After the Hybrid AAD-Join reboot the
/// agent restarts and waits for the user to sign in with their AD account. When nobody
/// signs in the agent goes silent until the backend watchdog fires; this detector emits a
/// <c>hybrid_login_pending</c> warning after a short single-shot timer so the operator sees
/// an explicit "still waiting for the sign-in" signal in the timeline.
/// <para>
/// Conditions checked at <see cref="Arm"/>: composition root must have already
/// confirmed a) post-reboot, b) <c>isHybridJoin == true</c>. The detector itself only
/// owns the timer + <see cref="AadJoinWatcher.AadUserJoined"/> cancel-path. No polling.
/// No periodicity. Fires at most once per agent process.
/// What it measures (rewritten 2026-09-04, session a7140f98): the ABSENCE of a real-user
/// desktop. Two cancel paths: the DesktopArrivalDetector resolving explorer.exe under a
/// real user (the actual sign-in evidence), and the AadJoinWatcher seeing a real user in
/// JoinInfo (the Entra-join flavour, where the OOBE sign-in completes the join). On a
/// Hybrid join the AD sign-in never replaces the JoinInfo placeholder — only the later
/// Entra device registration does — so the placeholder state is reported as a fact in the
/// payload, never as the reason for the warning.
/// </para>
/// <para>
/// Conditions checked at <see cref="Arm"/> by the composition root: post-reboot,
/// <c>isHybridJoin == true</c>, and not a WhiteGlove device (a sealed device legitimately
/// waits days for its user; the session status AwaitingUser already describes that).
/// The detector owns only the timer and the cancel paths. No polling. Fires at most once
/// per agent process.
/// </para>
/// </summary>
internal sealed class HybridLoginPendingDetector : IDisposable
@@ -40,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private bool _armed;
private bool _fired;
private bool _cancelledByRealUser;
private bool _cancelledByDesktop;
private int _disposed;
internal HybridLoginPendingDetector(
@@ -60,8 +69,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>
/// Starts the single-shot timer. Idempotent — repeated calls after the first arm
/// are no-ops. Has no effect if the watcher already saw a real AAD user (the
/// arming was racy and the cancel won), or if the detector already fired.
/// are no-ops. Has no effect if a real-user desktop or a real AAD user was already
/// observed (the arming was racy and the cancel won), or if the detector already fired.
/// </summary>
public void Arm()
{
@@ -69,6 +78,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
if (_disposed != 0) return;
if (_armed || _fired) return;
if (_cancelledByDesktop)
{
_logger.Info("HybridLoginPendingDetector: arm requested but a real-user desktop was already observed — skipped");
return;
}
if (_cancelledByRealUser)
{
_logger.Info("HybridLoginPendingDetector: arm requested but real AAD user already joined — skipped");
@@ -77,12 +91,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_armed = true;
_logger.Info(
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if real AAD user has not joined by then");
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if no real-user desktop appears by then");
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
}
}
/// <summary>
/// The DesktopArrivalDetector resolved explorer.exe under a real user — the sign-in
/// happened. Cancels a running timer; remembered so a later <see cref="Arm"/> is a no-op.
/// </summary>
public void NotifyRealUserDesktop()
{
lock (_lock)
{
_cancelledByDesktop = true;
if (!_armed || _fired) return;
_timer?.Dispose();
_timer = null;
_logger.Info("HybridLoginPendingDetector: cancelled — real-user desktop observed before timeout");
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
@@ -106,6 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
internal bool IsCancelledByRealUserForTest { get { lock (_lock) { return _cancelledByRealUser; } } }
internal bool IsCancelledByDesktopForTest { get { lock (_lock) { return _cancelledByDesktop; } } }
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
private void OnTimer(object? state) => EmitInternal(reason: "timer_fired");
@@ -130,23 +162,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// _armed guard (Codex review 2026-05-01): production is safe because the
// timer is only created in Arm(), but explicit guard makes the contract
// unambiguous — emission requires a deliberate Arm(), full stop.
if (!_armed || _fired || _cancelledByRealUser || _disposed != 0) return;
if (!_armed || _fired || _cancelledByRealUser || _cancelledByDesktop || _disposed != 0) return;
_fired = true;
_timer?.Dispose();
_timer = null;
}
var placeholderActive = _watcher.PlaceholderObservedWithoutRealUser ? "true" : "false";
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(),
["reason"] = reason,
["isHybridJoin"] = "true", // armed only when composition root verified this
["realUserDesktopSeen"] = "false",
["placeholderActive"] = placeholderActive,
};
_post.Emit(
eventType: SharedConstants.EventTypes.HybridLoginPending,
source: SourceLabel,
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real AD user — login overdue (placeholder still active in JoinInfo)",
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real-user desktop — sign-in overdue",
severity: EventSeverity.Warning,
immediateUpload: true,
data: data);
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
@@ -312,6 +313,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// later before the detector can fire). When the real-user desktop arrives, Shift+F10 is no
// longer possible, so the console watcher is stopped to avoid post-enrollment false positives.
ConsoleBypassHost? consoleBypassHost = null;
// Captured likewise (assigned right after this host). The real-user desktop is the
// sign-in evidence for the Hybrid detectors: it cancels hybrid_login_pending and arms
// the Entra user-affinity timer (2026-09-04, session a7140f98).
AadJoinHost? aadJoinHostRef = null;
var desktopArrivalHost = new DesktopArrivalHost(
logger,
ingress,
@@ -326,6 +331,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Shift+F10 works — stop the console watcher (no-op if not created / already stopped).
consoleBypassHost?.StopForDesktopArrival();
aadJoinHostRef?.NotifyRealUserDesktop();
if (realmJoinHost == null) return;
if (UserSidResolver.TryResolveSid(owner, out var sid) && !string.IsNullOrEmpty(sid))
{
@@ -340,8 +347,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var aadJoinHost = new AadJoinHost(
logger, ingress, clock,
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch);
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch,
isHybridJoinProbe: EnrollmentRegistryDetector.DetectHybridJoin);
hosts.Add(aadJoinHost);
aadJoinHostRef = aadJoinHost;
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
@@ -439,6 +448,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(imeLogHost);
imeLogHostRef = imeLogHost;
// Entra user affinity (2026-09-04): the raw IME token lines feed the AadJoinHost's
// affinity detector. New callbacks — nothing chained before us, so direct assignment.
imeLogHost.Tracker.OnUserTokenAcquired = lineUtc => aadJoinHost.NotifyUserTokenAcquired(lineUtc);
imeLogHost.Tracker.OnTokenFailureLine = (code, lineUtc) => aadJoinHost.NotifyTokenFailureLine(code, lineUtc);
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
// page exits while apps are still in flight, the single attempt misses, and nothing
@@ -258,6 +258,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public bool IsWhiteGlovePart2 => _isWhiteGlovePart2;
/// <summary>
/// <c>true</c> when this device ever resumed a WhiteGlove Part 2 in this session — the
/// state folder holds a <c>.part1-&lt;ts&gt;/</c> archive. Unlike
/// <see cref="IsWhiteGlovePart2"/> (this start only) it survives later reboots within
/// Part 2; see <see cref="StateArchiver.HasPart1Archive"/>.
/// </summary>
public bool HasWhiteGlovePart1Archive => StateArchiver.HasPart1Archive(_stateDirectory);
/// <summary>
/// <c>true</c> wenn der Start auf einen korrupten State-Segment traf und
/// Snapshot + Log-Segmente nach <c>.quarantine/{ts}/</c> bewegt wurden.
@@ -8,6 +8,13 @@ using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Hybrid-identity host: the JoinInfo watcher plus the two single-shot detectors that
/// describe the user side of a Hybrid Azure AD Join — sign-in overdue
/// (<see cref="HybridLoginPendingDetector"/>) and Entra user affinity after the sign-in
/// (<see cref="EntraUserAffinityDetector"/>). The composition root feeds it the
/// DesktopArrivalDetector's real-user observation and the ImeLogTracker's raw token lines.
/// </summary>
internal sealed class AadJoinHost : ICollectorHost
{
public string Name => "AadJoinWatcher";
@@ -15,41 +22,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly AadJoinWatcher _watcher;
private readonly AadJoinWatcherAdapter _adapter;
private readonly HybridLoginPendingDetector _hybridLoginPendingDetector;
private readonly EntraUserAffinityDetector _userAffinityDetector;
private int _disposed;
public AadJoinHost(
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
Action? onRealUserJoined = null)
Action? onRealUserJoined = null,
Func<bool>? isHybridJoinProbe = null)
{
_watcher = new AadJoinWatcher(logger);
_adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock, onRealUserJoined: onRealUserJoined);
var post = new InformationalEventPost(ingress, clock);
_hybridLoginPendingDetector = new HybridLoginPendingDetector(
watcher: _watcher,
post: new InformationalEventPost(ingress, clock),
post: post,
logger: logger);
_userAffinityDetector = new EntraUserAffinityDetector(
post: post,
logger: logger,
isHybridJoinProbe: isHybridJoinProbe ?? (() => false),
placeholderActiveProbe: () => _watcher.PlaceholderObservedWithoutRealUser,
utcNow: () => clock.UtcNow);
}
public void Start() => _watcher.Start();
public void Stop() => _watcher.Stop();
/// <summary>
/// Composition-root entry point for the Hybrid User-Driven completion-gap fix
/// (2026-05-01). Only call when both prerequisites hold: a) the prior agent process
/// was killed by an OS reboot (<c>previousExitType=reboot_kill</c>), b) the
/// Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>). The detector
/// itself enforces single-shot semantics + cancel-on-real-user — repeated calls or
/// late calls after a real user joined are safe no-ops.
/// Composition-root entry point for the Hybrid User-Driven sign-in-overdue warning
/// (2026-05-01, semantics 2026-09-04). Only call when all prerequisites hold: a) the
/// prior agent process was killed by an OS reboot (<c>previousExitType=reboot_kill</c>),
/// b) the Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>), c) the device
/// is not a WhiteGlove device (neither resuming Part 2 nor carrying a Part-1 archive).
/// The detector itself enforces single-shot semantics and both cancel paths —
/// repeated calls or late calls after a desktop / real user are safe no-ops.
/// </summary>
public void ArmHybridLoginPendingDetector() => _hybridLoginPendingDetector.Arm();
// Test seam — exposes the detector for unit tests in the V2.Core.Tests project.
/// <summary>
/// The DesktopArrivalDetector resolved explorer.exe under a real user: cancels the
/// sign-in-overdue timer and arms the user-affinity timer (Hybrid devices only).
/// </summary>
public void NotifyRealUserDesktop()
{
try { _hybridLoginPendingDetector.NotifyRealUserDesktop(); }
catch (Exception ex) { LogCallbackFailure("HybridLoginPendingDetector.NotifyRealUserDesktop", ex); }
try { _userAffinityDetector.NotifyRealUserDesktop(); }
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyRealUserDesktop", ex); }
}
/// <summary>ImeLogTracker observed an IME-TOKEN-SUCCESS line (raw, every line).</summary>
public void NotifyUserTokenAcquired(DateTime? lineUtc)
{
try { _userAffinityDetector.NotifyUserTokenAcquired(lineUtc); }
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyUserTokenAcquired", ex); }
}
/// <summary>ImeLogTracker observed an IME-TOKEN-FAILURE line (raw, every line).</summary>
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
{
try { _userAffinityDetector.NotifyTokenFailureLine(errorCode, lineUtc); }
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyTokenFailureLine", ex); }
}
// Test seams — expose the detectors for unit tests in the V2.Core.Tests project.
internal HybridLoginPendingDetector HybridLoginPendingDetectorForTest => _hybridLoginPendingDetector;
internal EntraUserAffinityDetector UserAffinityDetectorForTest => _userAffinityDetector;
private static void LogCallbackFailure(string what, Exception ex)
{
// Host callbacks are best-effort — a detector fault must never break the caller
// (DesktopArrivalDetector poll / ImeLogTracker drain). Swallowed on purpose; the
// detectors log their own state transitions.
_ = what; _ = ex;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _userAffinityDetector.Dispose(); } catch { }
try { _hybridLoginPendingDetector.Dispose(); } catch { }
try { _adapter.Dispose(); } catch { }
try { _watcher.Dispose(); } catch { }
@@ -37,6 +37,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public static class StateArchiver
{
internal const string Part1BucketPrefix = ".part1-";
/// <summary>
/// <c>true</c> when <paramref name="stateDirectory"/> holds at least one archived
/// <c>.part1-&lt;ts&gt;/</c> bucket — i.e. this device already went through a WhiteGlove
/// Part-1/Part-2 resume in this session. Durable across later reboots, unlike the
/// <c>whiteglove.complete</c> marker (cleared on resume) and the orchestrator's
/// in-memory Part-2 flag. Used to keep sign-in-overdue warnings off pre-provisioned
/// devices that legitimately wait days for their user (2026-09-04).
/// </summary>
public static bool HasPart1Archive(string stateDirectory)
{
try
{
if (string.IsNullOrEmpty(stateDirectory) || !Directory.Exists(stateDirectory)) return false;
return Directory.GetDirectories(stateDirectory, Part1BucketPrefix + "*").Length > 0;
}
catch
{
return false;
}
}
/// <summary>
/// Reducer-state segment files moved into the <c>.part1-&lt;ts&gt;/</c> bucket on
/// archive. <c>event-sequence.json</c> is intentionally NOT in this list — see the
@@ -86,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (!hasAny) return null;
var stamp = utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'");
var bucket = Path.Combine(stateDirectory, ".part1-" + stamp);
var bucket = Path.Combine(stateDirectory, Part1BucketPrefix + stamp);
Directory.CreateDirectory(bucket);
foreach (var name in KnownSegmentFiles)
@@ -0,0 +1,48 @@
#nullable enable
using System;
using System.Globalization;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// The install-time deployment marker: <c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, an
/// ISO-8601 UTC instant written by <c>--install</c> right after the runtime was handed off
/// (writer: <c>Program.InstallMode</c> in the runtime project). It is the bootstrap script's
/// re-entry lock and — since 2026-09-04 — the one instant the agent knows in UTC that also
/// appears in the IME logs: the Intune Management Extension logs the bootstrap script's
/// result a few hundred milliseconds later, which anchors the timezone of the pre-agent log
/// era (<c>ImeLogEraPreScan</c>). Read-only here; never written from Core.
/// </summary>
public static class DeploymentMarker
{
public const string RegistryKeyPath = @"SOFTWARE\AutopilotMonitor";
public const string RegistryValueName = "Deployed";
/// <summary>The marker's UTC instant, or <c>null</c> when absent or unparseable.</summary>
public static DateTime? TryReadDeployedUtc()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(RegistryKeyPath))
{
var raw = key?.GetValue(RegistryValueName) as string;
return Parse(raw);
}
}
catch
{
return null;
}
}
/// <summary>Pure parse of the marker text (round-trip "O" format); exposed for tests.</summary>
public static DateTime? Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
return null;
return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
}
}
}
@@ -464,6 +464,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
if (offsetMinutes.HasValue)
data["sourceOffsetMinutes"] = offsetMinutes.Value.ToString(culture);
// Which era anchor the offset came from — forensics for the era-anchored origin.
var anchorKind = _tracker.LastMatchedEraAnchorKind;
if (!string.IsNullOrEmpty(anchorKind))
data["sourceOffsetAnchor"] = anchorKind;
// Observational only — what the calibrator measured, which since the 2026-08-20
// revert is deliberately NOT what was applied. Kept separate so the two can never
// be read as the same thing.
@@ -489,6 +494,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
// The line anchored itself: read provably fresh, its own distance to the agent
// clock (grid-rounded) was applied as the writer's offset. Era-safe per line.
case CmTraceOffsetOrigin.LineAnchored: return "line-anchored";
// A backlog line resolved through its writer era, anchored by the bootstrap
// execution record against the install marker (ImeLogEraPreScan, 2026-09-04).
case CmTraceOffsetOrigin.EraAnchored: return "era-anchored";
// The line was not provably fresh (backlog, restart catch-up, replay), so this
// process's own zone was assumed — correct only if the writer happens to share it.
default: return "reader-zone-fallback";
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Signals;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
{
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
{
@@ -395,12 +395,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
}
}
private static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
/// <summary>
/// The batch body: a JSON array of <see cref="TelemetryItemDto"/> — the Shared class the
/// ingest deserialises — with Newtonsoft defaults (PascalCase, ISO-8601 UTC, Kind by name).
/// Internal so <c>TelemetryWireContractTests</c> can freeze the real output as a fixture.
/// </summary>
internal static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
{
// Newtonsoft PascalCase default + TelemetryItemKind carries [StringEnumConverter] →
// Backend routes by string "Event"/"Signal"/"DecisionTransition". Wire-format
// matches Plan §4.x M5 contract (PascalCase, StringEnum, ISO-8601 UTC).
return JsonConvert.SerializeObject(items, Formatting.None);
var wire = new TelemetryItemDto[items.Count];
for (var i = 0; i < items.Count; i++) wire[i] = items[i].ToWire();
return JsonConvert.SerializeObject(wire, Formatting.None);
}
private static byte[] CompressWithGzip(string text)
@@ -1,5 +1,6 @@
#nullable enable
using System;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
@@ -106,6 +107,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// <summary>Anzahl der bisher fehlgeschlagenen Upload-Versuche. Wird vom Orchestrator incrementiert.</summary>
public int RetryCount { get; }
/// <summary>
/// The wire shape of this item. <see cref="TelemetryItemDto"/> (Shared) is the ONE class both
/// ends serialise; this mapping is the only place the agent's validated model meets it.
/// <c>TelemetryWireContractTests</c> proves every DTO property is populated here and
/// freezes the resulting body under <c>tests/fixtures/telemetry-wire/</c> for the backend.
/// </summary>
public TelemetryItemDto ToWire() =>
new TelemetryItemDto
{
Kind = Kind.ToString(),
PartitionKey = PartitionKey,
RowKey = RowKey,
TelemetryItemId = TelemetryItemId,
SessionTraceOrdinal = SessionTraceOrdinal,
PayloadJson = PayloadJson,
RequiresImmediateFlush = RequiresImmediateFlush,
EnqueuedAtUtc = EnqueuedAtUtc,
RetryCount = RetryCount,
};
/// <summary>Copy-with-incremented <see cref="RetryCount"/>. Immutable-Erhalt (§2.3 L.3).</summary>
public TelemetryItem WithRetryIncremented() =>
new TelemetryItem(
@@ -1,5 +1,6 @@
#nullable enable
using System;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
@@ -1,29 +0,0 @@
#nullable enable
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
/// <summary>
/// Typ eines <see cref="TelemetryItem"/>. Plan §2.7a / L.10.
/// <para>
/// Drei getrennte Backend-Tabellen:
/// <see cref="Event"/> → <c>Events</c>,
/// <see cref="Signal"/> → <c>Signals</c>,
/// <see cref="DecisionTransition"/> → <c>DecisionTransitions</c>.
/// Der generische Transport routet auf Backend-Seite nach <c>Kind</c>.
/// </para>
/// <para>
/// String-serialisiert via <see cref="StringEnumConverter"/> — Backend-Routing darf
/// nicht von Integer-Ordering abhängen, das bei zukünftigen Erweiterungen bricht
/// (L.14 forward-compat).
/// </para>
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum TelemetryItemKind
{
Event = 0,
Signal,
DecisionTransition,
}
}
@@ -2,6 +2,7 @@
using System;
using AutopilotMonitor.DecisionCore.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
@@ -16,10 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// </summary>
public static class TelemetryItemSerializer
{
/// <summary>
/// DecisionCore settings plus a <see cref="StringEnumConverter"/>: <see cref="TelemetryItem.Kind"/>
/// is the only enum on a spool line and has always been written by NAME (<c>"Event"</c>),
/// which is also what the wire carries. The enum lives in Shared (no Newtonsoft there), so
/// the converter is registered here instead of as an attribute on the type.
/// </summary>
private static readonly JsonSerializerSettings SpoolSettings = CreateSpoolSettings();
private static JsonSerializerSettings CreateSpoolSettings()
{
var settings = DecisionCoreJsonSettings.Create();
settings.Converters.Add(new StringEnumConverter());
return settings;
}
public static string Serialize(TelemetryItem item)
{
if (item == null) throw new ArgumentNullException(nameof(item));
return JsonConvert.SerializeObject(item, DecisionCoreJsonSettings.Shared);
return JsonConvert.SerializeObject(item, SpoolSettings);
}
public static TelemetryItem Deserialize(string line)
@@ -28,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
try
{
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, DecisionCoreJsonSettings.Shared);
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, SpoolSettings);
if (result == null)
{
throw new JsonSerializationException($"TelemetryItem deserialization produced null: {line}");
@@ -37,8 +37,10 @@ namespace AutopilotMonitor.Agent.V2
/// </summary>
public static partial class Program
{
internal const string DeploymentRegistryKey = @"SOFTWARE\AutopilotMonitor";
internal const string DeploymentRegistryValue = "Deployed";
// Shared with the Core reader (DeploymentMarker) — the marker's UTC instant anchors the
// pre-agent IME log era, so writer and reader must agree on key, value and format.
internal const string DeploymentRegistryKey = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryKeyPath;
internal const string DeploymentRegistryValue = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryValueName;
internal const string BootstrapConfigFileName = "bootstrap-config.json";
internal const string AwaitEnrollmentConfigFileName = "await-enrollment.json";
private const string InstalledAgentExeName = "AutopilotMonitor.Agent.exe";
@@ -494,14 +494,19 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{
LifecycleEmitters.PostSystemRebootObserved(orchestrator.IngressSink, previousExit, logger);
// Hybrid User-Driven completion-gap fix (2026-05-01): the reboot is the
// expected switch from foouser/autopilot OOBE to the real AD account.
// If the user never logs in we go silent until the 5-h backend watchdog;
// arm a single-shot 10-min detector that emits hybrid_login_pending
// once if the AAD user join is overdue. Cancelled when AadJoinWatcher
// sees the real user. Only armed for actual Hybrid devices — non-Hybrid
// reboots don't have the placeholder→real-user flow that motivates this.
if (!isWhiteGloveResume && EnrollmentRegistryDetector.DetectHybridJoin())
// Hybrid User-Driven sign-in gap (2026-05-01, semantics 2026-09-04): after
// the Hybrid reboot the user has to sign in with the AD account. If nobody
// does, the agent goes silent until the backend watchdog; arm a single-shot
// 10-min detector that emits hybrid_login_pending once when no real-user
// desktop appeared by then. Cancelled by the DesktopArrivalDetector (the
// sign-in evidence) or by the AadJoinWatcher seeing a real user (Entra-join
// flavour). Never on WhiteGlove devices: a sealed device legitimately waits
// days for its user, and AwaitingUser already describes that — the Part-1
// archive check covers reboots inside Part 2 after the resume marker is gone.
if (!isWhiteGloveResume
&& !orchestrator.IsWhiteGlovePart2
&& !orchestrator.HasWhiteGlovePart1Archive
&& EnrollmentRegistryDetector.DetectHybridJoin())
{
try { orchestrator.CollectorSurfaces?.AadJoinHost?.ArmHybridLoginPendingDetector(); }
catch (Exception ex)
@@ -11,9 +11,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Every payload-derived string that lands in <see cref="DecisionState"/> is bounded by
/// <see cref="FactStringBounds"/> at the reducer boundary. The server-side semantic replay
/// (<c>ReducerVerifier</c>) folds device-uploaded payloads through the same engine, so an
/// unbounded string there is a per-request memory/CPU amplifier. These tests pin that the
/// <see cref="FactStringBounds"/> at the reducer boundary — an unbounded string there is a
/// per-signal memory/CPU amplifier in every snapshot persist. These tests pin that the
/// bound is applied BEFORE any dedupe comparison (so a forged multi-hundred-KB key neither
/// costs a full-length compare nor lands in state) and that real-sized values pass through
/// unchanged.
@@ -1,34 +0,0 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value + round-trip tests for <see cref="AdminConfiguration.EnableIndexDualWrite"/>
/// (Plan §M5.d). The flag gates the index-table dual-write rollout — defaulting to false
/// keeps pre-M5.d behaviour bit-exact and lets operators flip it on deliberately.
/// </summary>
public class AdminConfigurationEnableIndexDualWriteTests
{
[Fact]
public void EnableIndexDualWrite_defaults_to_false_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.False(cfg.EnableIndexDualWrite);
}
[Fact]
public void EnableIndexDualWrite_defaults_to_false_on_CreateDefault()
{
// CreateDefault() is called when no admin-config row exists yet — if it somehow
// enabled dual-write it would break the "default-off" contract immediately.
var cfg = AdminConfiguration.CreateDefault();
Assert.False(cfg.EnableIndexDualWrite);
}
[Fact]
public void EnableIndexDualWrite_true_persists_on_the_config()
{
var cfg = new AdminConfiguration { EnableIndexDualWrite = true };
Assert.True(cfg.EnableIndexDualWrite);
}
}
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// JSON round-trip tests for <see cref="AnalyzeOnEnrollmentEndEnvelope"/>. The producer
/// (<c>AzureQueueAnalyzeOnEnrollmentEndProducer</c>) serializes with Newtonsoft and the
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueWorker</c>) deserializes the same shape —
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueFunction</c>) deserializes the same shape —
/// pinning the contract here keeps the queue from silently breaking when fields are added.
/// </summary>
public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
@@ -0,0 +1,103 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The middleware-side error-envelope writer: status, content type, Retry-After and the exact body
/// (production wire options — camelCase, absent-when-null) for the generic envelope and for a
/// specialised <see cref="IApiErrorResponse"/>. Whatever the authentication, policy, rate-limit and
/// quota middlewares write flows through here, so this is the one place their body shape is pinned.
/// </summary>
public class ApiErrorWriterTests
{
[Fact]
public async Task Generic_envelope_writes_status_content_type_and_the_three_prefix_keys()
{
var ctx = NewContext();
await ApiErrorWriter.WriteAsync(ctx, "cid-42", HttpStatusCode.Unauthorized,
Constants.ApiErrorCodes.AuthenticationRequired, "Authentication required.");
Assert.Equal(401, ctx.Response.StatusCode);
Assert.StartsWith("application/json", ctx.Response.ContentType);
Assert.False(ctx.Response.Headers.ContainsKey("Retry-After"));
Assert.Equal(
"{\"error\":\"Authentication required.\",\"code\":\"AuthenticationRequired\",\"correlationId\":\"cid-42\"}",
Body(ctx));
}
[Fact]
public async Task Generic_envelope_with_retry_window_sets_the_header_and_mirrors_it_in_the_body()
{
var ctx = NewContext();
await ApiErrorWriter.WriteAsync(ctx, "cid-7", HttpStatusCode.TooManyRequests,
Constants.ApiErrorCodes.RateLimited, "Rate limit exceeded: 60 requests per minute",
retryAfterSeconds: 23);
Assert.Equal(429, ctx.Response.StatusCode);
Assert.Equal("23", ctx.Response.Headers["Retry-After"]);
Assert.Equal(
"{\"error\":\"Rate limit exceeded: 60 requests per minute\",\"code\":\"RateLimited\",\"correlationId\":\"cid-7\",\"retryAfterSeconds\":23}",
Body(ctx));
}
[Fact]
public async Task Generic_envelope_carries_hint_and_operation_only_when_given()
{
var ctx = NewContext();
await ApiErrorWriter.WriteAsync(ctx, "cid-9", HttpStatusCode.InternalServerError,
Constants.ApiErrorCodes.InternalError, "QueryTable failed.",
hint: "Retry with a narrower filter.", operation: "QueryTable");
Assert.Equal(
"{\"error\":\"QueryTable failed.\",\"code\":\"InternalError\",\"correlationId\":\"cid-9\"," +
"\"hint\":\"Retry with a narrower filter.\",\"operation\":\"QueryTable\"}",
Body(ctx));
}
[Fact]
public async Task Specialised_body_is_stamped_with_the_correlation_id_and_serialized_as_its_runtime_type()
{
var ctx = NewContext();
var body = new DelegatedSlotLimitReachedResponse
{
Error = "Slot limit reached.",
HomeTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001",
Used = 2,
Limit = 2,
Required = 1,
};
await ApiErrorWriter.WriteAsync(ctx, "cid-slot", HttpStatusCode.Conflict, body);
Assert.Equal(409, ctx.Response.StatusCode);
Assert.Equal("cid-slot", body.CorrelationId);
var json = JsonDocument.Parse(Body(ctx)).RootElement;
// Envelope prefix first, then the specialised fields — and nothing of the marker interface.
var keys = json.EnumerateObject().Select(p => p.Name).ToArray();
Assert.Equal(new[] { "error", "code", "correlationId", "homeTenantId", "used", "limit", "required" }, keys);
Assert.Equal("DelegatedSlotLimitReached", json.GetProperty("code").GetString());
}
private static DefaultHttpContext NewContext()
{
var ctx = new DefaultHttpContext();
ctx.Response.Body = new MemoryStream();
return ctx;
}
private static string Body(HttpContext ctx)
{
ctx.Response.Body.Position = 0;
using var reader = new StreamReader(ctx.Response.Body);
return reader.ReadToEnd();
}
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -29,6 +30,33 @@ public class ApiResponseWireParityTests
Assert.Equal(expected, actual);
}
// ---- Error envelope (2026-09, deliberate wire change) ----------------------------------
// Non-2xx bodies used to be ~10 anonymous shapes ({ success = false, message }, { error },
// { error, message }, { error, code }, …). They are ONE typed envelope now, so these pins fix
// the NEW shape exactly rather than proving parity with any single legacy literal.
[Fact]
public void ApiErrorResponse_pins_the_minimal_envelope_shape()
{
var body = ApiErrorWriter.Build("cid-1", Constants.ApiErrorCodes.NotFound, "Session not found.");
Assert.Equal(
"{\"error\":\"Session not found.\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}",
JsonSerializer.Serialize(body, WireOptions));
}
[Fact]
public void ApiErrorResponse_pins_the_full_envelope_shape_and_key_order()
{
var body = ApiErrorWriter.Build("cid-2", Constants.ApiErrorCodes.ServiceUnavailable,
"Authorization service temporarily unavailable.", hint: "Retry.", retryAfterSeconds: 5, operation: "PolicyEnforcement");
Assert.Equal(
"{\"error\":\"Authorization service temporarily unavailable.\",\"code\":\"ServiceUnavailable\"," +
"\"correlationId\":\"cid-2\",\"hint\":\"Retry.\",\"retryAfterSeconds\":5,\"operation\":\"PolicyEnforcement\"}",
JsonSerializer.Serialize(body, WireOptions));
}
[Fact]
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
{
@@ -38,6 +38,30 @@ public class AppInsightsQueryFunctionTests
Assert.Equal(expected, AppInsightsQueryFunction.ClampBudget(requested));
}
// ── Upstream failure → status the caller sees. Query errors are the caller's (400, rendered in full
// by the MCP); a missing managed-identity grant (401/403) and store failures (5xx) are 502. ──
[Theory]
[InlineData(400, System.Net.HttpStatusCode.BadRequest)] // SyntaxError / SemanticError / BadArgumentError
[InlineData(404, System.Net.HttpStatusCode.BadRequest)] // unknown app id in the setting: still a config/caller problem, not the store
[InlineData(401, System.Net.HttpStatusCode.BadGateway)]
[InlineData(403, System.Net.HttpStatusCode.BadGateway)]
[InlineData(429, System.Net.HttpStatusCode.BadRequest)]
[InlineData(500, System.Net.HttpStatusCode.BadGateway)]
[InlineData(503, System.Net.HttpStatusCode.BadGateway)]
public void Upstream_failures_map_to_400_for_the_query_and_502_for_grant_or_store(int upstream, System.Net.HttpStatusCode expected)
{
Assert.Equal(expected, AppInsightsQueryFunction.MapUpstreamFailure((System.Net.HttpStatusCode)upstream));
}
[Fact]
public void Hints_name_the_query_problem_or_the_missing_grant()
{
Assert.Contains("syntax error", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.BadRequest, "SyntaxError", "backend")!);
Assert.Contains("getschema", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.BadRequest, "SemanticError", "web")!);
Assert.Contains("Log Analytics Reader", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.Forbidden, null, "mcp")!);
Assert.Null(AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.InternalServerError, "InternalError", "backend"));
}
// ── Kusto body → typed tables; cells forwarded as the JSON the store produced ──
[Fact]
public void Kusto_body_is_parsed_cell_for_cell_and_complete_results_carry_no_partial()
@@ -0,0 +1,362 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Middleware;
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.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Application principals — a service principal presenting an app-only token (idtyp=app, typically behind a
/// federated credential) — flow through the same role tables as a person under the key
/// <c>app:&lt;client-id&gt;</c>. These tests pin the four places where that is decided: the classification
/// (<see cref="ClaimsPrincipalExtensions.GetUserPrincipalName"/>), the authentication gate
/// (<see cref="AuthenticationMiddleware.ValidateApplicationPrincipal"/>: the access_as_application
/// permission is mandatory), the roleless-tier deny in the policy middleware (an application is never
/// "some authenticated person"), and the caps — Viewer in a tenant, DelegatedReader on managed tenants,
/// never a platform role — enforced at resolution rather than only at grant time.
/// </summary>
public class ApplicationPrincipalTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string AppId = "AAAAAAAA-0000-0000-0000-00000000AAAA";
private const string AppOid = "bbbbbbbb-0000-0000-0000-0000bbbbbbbb";
private static readonly string AppKey = Constants.PrincipalKeys.ForApplication(AppId);
private static ClaimsPrincipal AppPrincipal(
string tenantId = TenantA, string? appId = AppId, string? oid = AppOid, bool withPermission = true,
string appIdClaim = "appid", bool idtyp = true)
{
var claims = new List<Claim> { new("tid", tenantId) };
if (idtyp) claims.Add(new Claim("idtyp", "app"));
if (appId != null) claims.Add(new Claim(appIdClaim, appId));
if (oid != null) claims.Add(new Claim("oid", oid));
if (withPermission) claims.Add(new Claim("roles", Constants.ApplicationPermissions.AccessAsApplication));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private static ClaimsPrincipal UserPrincipal(string upn = "alice@contoso.example", string tenantId = TenantA)
=> new(new ClaimsIdentity(new[]
{
new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", "cccccccc-0000-0000-0000-0000cccccccc"),
new Claim("scp", "access_as_user"),
}, "TestAuth"));
// ── Classification: the principal key ───────────────────────────────────────
[Theory]
[InlineData("appid")]
[InlineData("azp")]
public void AppOnlyToken_YieldsTheApplicationKey_FromV1AndV2Claims(string claim)
{
var principal = AppPrincipal(appIdClaim: claim);
Assert.True(principal.IsApplicationPrincipal());
Assert.Equal(AppId.ToLowerInvariant(), principal.GetApplicationId());
Assert.Equal($"app:{AppId.ToLowerInvariant()}", principal.GetUserPrincipalName());
}
[Fact]
public void TokenWithoutIdtyp_IsNotAnApplication_AndHasNoKey()
{
// Fail-closed classification: no idtyp ⇒ a person, who then needs a UPN like everyone else.
var principal = AppPrincipal(idtyp: false);
Assert.False(principal.IsApplicationPrincipal());
Assert.Null(principal.GetUserPrincipalName());
Assert.Null(AdminIdentity.FromPrincipal(principal));
}
[Fact]
public void UserToken_KeepsItsUpn_EvenWhenAnAppIdIsPresent()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("tid", TenantA), new Claim("upn", "Alice@Contoso.Example"), new Claim("appid", AppId),
}, "TestAuth"));
Assert.Equal("Alice@Contoso.Example", principal.GetUserPrincipalName());
Assert.False(principal.IsApplicationPrincipal());
}
[Fact]
public void AdminIdentity_OfAnApplication_IsFlagged()
{
var identity = AdminIdentity.FromPrincipal(AppPrincipal());
Assert.NotNull(identity);
Assert.True(identity!.IsApplication);
Assert.Equal(AppKey, identity.Upn);
Assert.Equal(AppOid, identity.ObjectId);
Assert.False(AdminIdentity.FromPrincipal(UserPrincipal())!.IsApplication);
}
[Fact]
public void PrincipalKeys_CannotCollideWithAUpn()
{
Assert.True(Constants.PrincipalKeys.IsApplication(AppKey));
Assert.False(Constants.PrincipalKeys.IsApplication("app@contoso.example"));
Assert.Equal(AppId.ToLowerInvariant(), Constants.PrincipalKeys.TryGetApplicationId(AppKey));
Assert.Null(Constants.PrincipalKeys.TryGetApplicationId("alice@contoso.example"));
}
// ── Authentication gate ─────────────────────────────────────────────────────
[Fact]
public void Gate_PassesUserTokens_Untouched()
=> Assert.Null(AuthenticationMiddleware.ValidateApplicationPrincipal(UserPrincipal()));
[Fact]
public void Gate_PassesAnAdmissibleAppToken()
=> Assert.Null(AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal()));
[Fact]
public void Gate_RejectsAnAppToken_WithoutTheApplicationPermission()
{
var reason = AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(withPermission: false));
Assert.NotNull(reason);
Assert.Contains(Constants.ApplicationPermissions.AccessAsApplication, reason);
}
[Fact]
public void Gate_RejectsAnAppToken_WithoutApplicationIdOrObjectId()
{
Assert.Contains("application id", AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(appId: null))!);
Assert.Contains("object id", AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(oid: null))!);
}
// ── Policy middleware: roleless tiers and caps ──────────────────────────────
private sealed class Harness
{
public required PolicyEnforcementMiddleware Middleware { get; init; }
public required Mock<IAdminRepository> Repo { get; init; }
public void MemberRow(string role, bool enabled = true) =>
Repo.Setup(r => r.GetTenantMemberAsync(TenantA, AppKey))
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = AppKey, Role = role, IsEnabled = enabled });
}
private static Harness BuildHarness()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>());
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
.ReturnsAsync(new AdminIdentityBinding { TenantId = TenantA, ObjectId = AppOid });
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
var delegatedAdmin = new DelegatedAdminService(
repo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var mw = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig, new RecordingDenialReporter());
return new Harness { Middleware = mw, Repo = repo };
}
[Theory]
[InlineData("GET", "/api/feedback/status")] // AuthenticatedUser
[InlineData("GET", "/api/auth/me")] // AuthenticatedUser
[InlineData("POST", "/api/realtime/groups/join")] // AuthenticatedUserWithRole
public async Task RolelessTiers_DenyAnApplication_UnlessTheEntryOptsIn(string method, string path)
{
var h = BuildHarness();
h.MemberRow(Constants.TenantRoles.Viewer);
var r = await h.Middleware.DecideAsync(method, path, null, AppPrincipal());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal("ApplicationPrincipalNotAllowed", r.LogReason);
}
[Fact]
public async Task TheMcpFrontDoor_AdmitsAnApplication()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/auth/mcp", null, AppPrincipal());
Assert.True(r.Allowed);
Assert.Equal(AppKey, r.Context!.UserPrincipalName);
Assert.Equal(AppOid, r.Context.ObjectId);
}
[Fact]
public async Task MemberRead_AdmitsAnApplicationMember_CappedToViewer()
{
var h = BuildHarness();
h.MemberRow(Constants.TenantRoles.Admin); // a row edited to Admin still reads as Viewer
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AppPrincipal());
Assert.True(r.Allowed);
Assert.Equal(Constants.TenantRoles.Viewer, r.Context!.UserRole);
Assert.False(r.Context.IsTenantAdmin);
}
[Fact]
public async Task MemberRead_DeniesAnApplication_WithoutARow()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AppPrincipal());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
}
[Theory]
[InlineData("POST", "/api/sessions/abc/actions")] // TenantAdminOrOperator
[InlineData("PUT", "/api/sessions/abc/annotations/operator")]
[InlineData("POST", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
public async Task WriteTiers_NeverAdmitAnApplication_EvenWithAnAdminRow(string method, string path)
{
var h = BuildHarness();
h.MemberRow(Constants.TenantRoles.Admin);
var r = await h.Middleware.DecideAsync(method, path, null, AppPrincipal());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
}
[Fact]
public async Task PlatformRoleRow_IsInert_ForAnApplication()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetGlobalRoleAsync(AppKey)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AppPrincipal());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
}
[Fact]
public async Task DelegatedScope_OfAnApplication_IsCappedToReader()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetDelegatedTenantsAsync(AppKey)).ReturnsAsync(new List<DelegatedAdminEntry>
{
new()
{
TenantId = TenantB, Role = Constants.DelegatedRoles.DelegatedAdmin, IsEnabled = true,
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
},
});
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AppPrincipal());
Assert.True(r.Allowed);
Assert.True(r.Context!.IsDelegatedReader);
Assert.False(r.Context.IsDelegatedAdmin);
}
// ── Member-role resolver cap ────────────────────────────────────────────────
[Theory]
[InlineData(Constants.TenantRoles.Admin)]
[InlineData(Constants.TenantRoles.Operator)]
[InlineData(Constants.TenantRoles.Viewer)]
public async Task Resolver_CapsAnApplicationRow_ToViewer(string rowRole)
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetTenantMemberAsync(TenantA, AppKey))
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = AppKey, Role = rowRole, IsEnabled = true });
var sut = Resolver(repo);
var role = await sut.ResolveAsync(TenantA, AppKey, new[] { Constants.ApplicationPermissions.AccessAsApplication });
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
Assert.False(role!.CanManageBootstrapTokens);
}
[Fact]
public async Task Resolver_NeverDerivesAnApplicationRole_FromClaims()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
var sut = Resolver(repo, appRolesEnabled: true);
// Even a (hypothetical) Admin app-role claim on an app token grants nothing without a row.
var role = await sut.ResolveAsync(TenantA, AppKey, new[] { Constants.TenantRoles.Admin });
Assert.Null(role);
}
private static TenantMemberRoleResolver Resolver(Mock<IAdminRepository> repo, bool appRolesEnabled = false)
{
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantA, EntraAppRolesEnabled = appRolesEnabled });
var cache = new MemoryCache(new MemoryCacheOptions());
return new TenantMemberRoleResolver(
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache));
}
// ── MCP front door ──────────────────────────────────────────────────────────
[Fact]
public async Task McpAccess_ForAnApplicationMember_IsAllMembers_AndNamesTheKey()
{
var (sut, members) = McpService();
members.Verdict = (tid, key, _) => tid == TenantA && key == AppKey ? new MemberRoleInfo { Role = Constants.TenantRoles.Viewer } : null;
var result = await sut.IsAllowedAsync(AppKey, TenantA, AppOid, new[] { Constants.ApplicationPermissions.AccessAsApplication });
Assert.True(result.IsAllowed);
Assert.Equal("AllMembers", result.AccessGrant);
Assert.Equal(AppKey, result.Upn);
}
[Fact]
public async Task McpAccess_ForAnApplication_WithoutMembership_SaysSo()
{
var (sut, _) = McpService();
var result = await sut.IsAllowedAsync(AppKey, TenantA, AppOid);
Assert.False(result.IsAllowed);
Assert.Contains("Service principal", result.Reason);
}
private static (McpUserService Sut, StubTenantMemberRoleResolver Members) McpService()
{
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var bindings = new StubAdminIdentityBindingService(bound: true);
var globalAdmin = new Mock<GlobalAdminService>(adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
var delegatedAdmin = new Mock<DelegatedAdminService>(
adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
var members = new StubTenantMemberRoleResolver();
var sut = new McpUserService(
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members);
return (sut, members);
}
}
@@ -61,7 +61,8 @@ public class AzureQueueImeMsiArchiveProducerTests
public async Task EnqueueAsync_send_failure_is_swallowed()
{
var (producer, queue, _) = BuildSut();
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
queue.Setup(q => q.SendMessageAsync(
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "queue busy"));
// Fire-and-forget continuation on the ingest path — must never throw.
@@ -107,8 +108,9 @@ public class AzureQueueImeMsiArchiveProducerTests
queue.Setup(q => q.CreateIfNotExistsAsync(
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response?)null);
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns<string, CancellationToken>((body, _) =>
queue.Setup(q => q.SendMessageAsync(
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
.Returns<string, TimeSpan?, TimeSpan?, CancellationToken>((body, _, _, _) =>
{
sentBodies.Add(body);
var receipt = QueuesModelFactory.SendReceipt(
@@ -476,11 +476,13 @@ public class ConfigWireParityTests
trialExpiresUtc,
trialConsumed = true,
effectiveEdition = "pro",
editionSource = "plan",
retentionGraceEndsUtc,
maxDelegatedTenants = 5,
maxDelegatedTenantsOverride = 5,
mcpUsagePlan = "msp",
mcpUsagePlanOverride = "msp"
mcpUsagePlanOverride = "msp",
payingCustomer = true
},
new SetTenantPlanTierResponse
{
@@ -489,11 +491,13 @@ public class ConfigWireParityTests
TrialExpiresUtc = trialExpiresUtc,
TrialConsumed = true,
EffectiveEdition = "pro",
EditionSource = "plan",
RetentionGraceEndsUtc = retentionGraceEndsUtc,
MaxDelegatedTenants = 5,
MaxDelegatedTenantsOverride = 5,
McpUsagePlan = "msp",
McpUsagePlanOverride = "msp"
McpUsagePlanOverride = "msp",
PayingCustomer = true
});
}
@@ -514,11 +518,13 @@ public class ConfigWireParityTests
trialExpiresUtc,
trialConsumed = false,
effectiveEdition = "community",
editionSource = "community",
retentionGraceEndsUtc,
maxDelegatedTenants = 0,
maxDelegatedTenantsOverride,
mcpUsagePlan = "community",
mcpUsagePlanOverride
mcpUsagePlanOverride,
payingCustomer = false
},
new SetTenantPlanTierResponse
{
@@ -527,11 +533,13 @@ public class ConfigWireParityTests
TrialExpiresUtc = null,
TrialConsumed = false,
EffectiveEdition = "community",
EditionSource = "community",
RetentionGraceEndsUtc = null,
MaxDelegatedTenants = 0,
MaxDelegatedTenantsOverride = null,
McpUsagePlan = "community",
McpUsagePlanOverride = null
McpUsagePlanOverride = null,
PayingCustomer = false
});
}
@@ -0,0 +1,83 @@
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Config;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Concurrent cache misses on the two configuration services share one repository read
/// (audit 2026-09-05 F12). The MemoryCache stays: the hit path and the in-place projection
/// semantics are untouched, only the miss path is single-flighted.
/// </summary>
public class ConfigurationServiceSingleFlightTests
{
[Fact]
public async Task Admin_config_concurrent_misses_issue_one_repository_read()
{
var gate = new TaskCompletionSource<AdminConfiguration?>();
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync()).Returns(gate.Task);
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync()).ToArray();
gate.SetResult(AdminConfiguration.CreateDefault());
var results = await Task.WhenAll(calls);
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Once);
Assert.All(results, r => Assert.Same(results[0], r));
}
[Fact]
public async Task Admin_config_after_completion_the_next_miss_reads_again()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync(AdminConfiguration.CreateDefault());
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
await sut.GetConfigurationAsync();
sut.InvalidateCache();
await sut.GetConfigurationAsync();
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Exactly(2));
}
[Fact]
public async Task Tenant_config_concurrent_misses_for_one_tenant_issue_one_repository_read()
{
const string tenantId = "11111111-1111-1111-1111-111111111111";
var gate = new TaskCompletionSource<TenantConfiguration?>();
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetTenantConfigurationAsync(tenantId)).Returns(gate.Task);
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync(tenantId)).ToArray();
gate.SetResult(TenantConfiguration.CreateDefault(tenantId));
var results = await Task.WhenAll(calls);
repo.Verify(r => r.GetTenantConfigurationAsync(tenantId), Times.Once);
Assert.All(results, r => Assert.Same(results[0], r));
}
[Fact]
public async Task Tenant_config_different_tenants_do_not_share_a_flight()
{
const string a = "11111111-1111-1111-1111-111111111111";
const string b = "22222222-2222-2222-2222-222222222222";
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.Returns<string>(id => Task.FromResult<TenantConfiguration?>(TenantConfiguration.CreateDefault(id)));
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
await Task.WhenAll(sut.GetConfigurationAsync(a), sut.GetConfigurationAsync(b));
repo.Verify(r => r.GetTenantConfigurationAsync(a), Times.Once);
repo.Verify(r => r.GetTenantConfigurationAsync(b), Times.Once);
}
}
@@ -1,179 +0,0 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="DecisionGraphBuilder"/> — pure projection, zero IO. Covers node
/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and
/// empty / out-of-order inputs.
/// </summary>
public class DecisionGraphBuilderTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static DecisionTransitionRecord Transition(
int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t",
string reducer = "1.0.0")
=> new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = step,
FromStage = from,
ToStage = to,
Taken = taken,
DeadEndReason = deadEnd,
Trigger = trigger,
ReducerVersion = reducer,
IsTerminal = DecisionGraphBuilder.IsTerminalStage(to),
OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc),
};
// ============================================================ Empty input
[Fact]
public void Build_returns_empty_projection_when_no_transitions()
{
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty<DecisionTransitionRecord>());
Assert.Equal(TenantId, graph.TenantId);
Assert.Equal(SessionId, graph.SessionId);
Assert.Empty(graph.Nodes);
Assert.Empty(graph.Edges);
Assert.Equal(string.Empty, graph.ReducerVersion);
}
// ============================================================ Node de-duplication
[Fact]
public void Build_deduplicates_stages_into_unique_nodes()
{
// Same stage visited twice through different paths — appears once in Nodes.
var transitions = new[]
{
Transition(0, "SessionStarted", "EspInProgress"),
Transition(1, "EspInProgress", "AccountSetup"),
Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical)
Transition(3, "EspInProgress", "Completed"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
// Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes.
Assert.Equal(4, graph.Nodes.Count);
Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted");
Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress");
Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup");
Assert.Contains(graph.Nodes, n => n.Id == "Completed");
}
// ============================================================ Terminal classification
[Theory]
[InlineData("Completed", "Succeeded")]
[InlineData("Failed", "Failed")]
[InlineData("WhiteGloveSealed", "PausedForPart2")]
public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome)
{
var transitions = new[]
{
Transition(0, "Start", terminalStage),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
var node = graph.Nodes.Single(n => n.Id == terminalStage);
Assert.True(node.IsTerminal);
Assert.Equal(expectedOutcome, node.TerminalOutcome);
}
[Fact]
public void Build_leaves_non_terminal_nodes_unmarked()
{
var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") };
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.All(graph.Nodes, n =>
{
Assert.False(n.IsTerminal);
Assert.Null(n.TerminalOutcome);
});
}
// ============================================================ Edges
[Fact]
public void Build_preserves_all_edges_including_dead_ends()
{
var transitions = new[]
{
Transition(0, "EspInProgress", "Completed", taken: true),
Transition(1, "EspInProgress", "EspInProgress", taken: false,
deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal(2, graph.Edges.Count);
Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed");
var deadEnd = graph.Edges.Single(e => !e.Taken);
Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason);
Assert.Equal("EspExiting", deadEnd.Trigger);
}
[Fact]
public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled()
{
// Repository sorts, but defend anyway — the builder is used directly in tests and
// could be fed arbitrary lists.
var transitions = new[]
{
Transition(2, "AccountSetup", "Completed"),
Transition(0, "SessionStarted", "EspInProgress"),
Transition(1, "EspInProgress", "AccountSetup"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex));
}
// ============================================================ VisitCount
[Fact]
public void Build_counts_only_taken_transitions_toward_VisitCount()
{
var transitions = new[]
{
Transition(0, "Start", "EspInProgress", taken: true),
Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"),
Transition(2, "EspInProgress", "Completed", taken: true),
Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
var esp = graph.Nodes.Single(n => n.Id == "EspInProgress");
Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count
var completed = graph.Nodes.Single(n => n.Id == "Completed");
Assert.Equal(1, completed.VisitCount);
}
[Fact]
public void Build_exposes_ReducerVersion_from_first_row()
{
var transitions = new[]
{
Transition(0, "Start", "EspInProgress", reducer: "1.4.2"),
Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal("1.4.2", graph.ReducerVersion);
}
}
@@ -67,6 +67,32 @@ public class DelegatedAdminEditionGateTests
Assert.True(scope.Covers(ManagedTenantB));
}
private const string ConferredProHomeTenant = "66666666-6666-6666-6666-666666666666";
[Fact]
public async Task ConferredProHomeTenant_SuppressesEntireScope_NoTransitiveDelegation()
{
// The home tenant is Pro only because ANOTHER tenant manages it: the gate is the delegation
// ENTITLEMENT (absent from conferred Pro), not the edition.
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry> { Row(ManagedTenantA) });
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
var svc = new DelegatedAdminService(
repo.Object,
new StubAdminIdentityBindingService(bound: true),
new StubTenantEntitlementService(
tenantId => tenantId == ConferredProHomeTenant
? new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false)
: new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false),
planOverrideResolver: null),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
var scope = await svc.GetScopeAsync(Id(ConferredProHomeTenant));
Assert.True(scope.IsEmpty);
}
[Fact]
public async Task CommunityHomeTenant_SuppressesEntireScope()
{
@@ -42,6 +42,7 @@ public class DelegationSelfServiceTests
public required FakeSignalRNotificationService SignalR { get; init; }
public required List<DelegationInvitation> Rows { get; init; }
public required List<string> GroupTenants { get; init; }
public required Mock<ProConferralService> ProConferral { get; init; }
}
private static Harness Build(TenantEdition homeEdition = TenantEdition.Pro, int? slotOverride = null)
@@ -111,9 +112,66 @@ public class DelegationSelfServiceTests
.ReturnsAsync(true);
var signalR = new FakeSignalRNotificationService();
// Conferred Pro is the projection's business — here we only pin that the accept/end paths notify it.
var proConferral = new Mock<ProConferralService>(
repo.Object,
new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache),
ManagedTenantProIndex.None,
NullLogger<ProConferralService>.Instance) { CallBase = false };
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
NullLogger<DelegationSelfService>.Instance, time);
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants };
proConferral.Object, NullLogger<DelegationSelfService>.Instance, time);
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
}
// ── Conferred Pro hooks ───────────────────────────────────────────────────────
[Fact]
public async Task Accept_NotifiesConferredProProjection_ForTheCustomer()
{
var h = Build();
h.Rows.Add(Pending());
var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin);
Assert.True(r.Ok);
h.ProConferral.Verify(p => p.NotifyDelegationChangedAsync(Customer), Times.Once);
h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RemoveManaged_RecordsConferredProLoss_WithTheAuditReason()
{
var h = Build();
h.GroupTenants.Add(Customer);
var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
Assert.True(r.Ok);
h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-removed-by-manager"), Times.Once);
}
[Fact]
public async Task RevokeManager_RecordsConferredProLoss_WithTheAuditReason()
{
var h = Build();
h.GroupTenants.Add(Customer);
var r = await h.Svc.RevokeManagerAsync(Customer, Home, CustomerAdmin);
Assert.True(r.Ok);
h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-revoked"), Times.Once);
}
[Fact]
public async Task RemoveManaged_NotAMember_RecordsNoLoss()
{
var h = Build();
var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
Assert.False(r.Ok);
h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
private static DelegationInvitation Pending(string id = "inv1", DateTime? expires = null) => new()
@@ -27,6 +27,7 @@ public class DelegationWireParityTests
{
error = "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
code = "DelegatedSlotLimitReached",
correlationId = "",
homeTenantId = Home,
homeTenantDomain = "partner.example",
used = 2,
@@ -47,6 +48,7 @@ public class DelegationWireParityTests
{
error = $"Delegated tenant slot limit reached for {Home}: 3 of 3 slot(s) in use, 2 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
code = "DelegatedSlotLimitReached",
correlationId = "",
homeTenantId = Home,
homeTenantDomain,
used = 3,
@@ -1,8 +1,9 @@
using System.Net;
using System.Reflection;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -59,6 +60,9 @@ public class DeleteSessionFunctionTests
}
// ── BuildV2ResponseBody ───────────────────────────────────────────────────────────────
// The success arm is SessionDeletionQueuedResponse; every rejected arm is the typed error body
// SessionDeletionRejectedResponse (envelope prefix error/code/correlationId + lock diagnostics).
// The correlation id is stamped by the writer, so the builder leaves it empty here.
[Fact]
public void BuildV2ResponseBody_Enqueued_carries_manifestId_and_queued_status()
@@ -69,15 +73,15 @@ public class DeleteSessionFunctionTests
ManifestId = ManifestId,
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
var body = Assert.IsType<SessionDeletionQueuedResponse>(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
AssertProperty(body, "success", true);
AssertProperty(body, "status", "queued");
AssertProperty(body, "manifestId", ManifestId);
Assert.True(body.Success);
Assert.Equal("queued", body.Status);
Assert.Equal(ManifestId, body.ManifestId);
}
[Fact]
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_hint()
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_code()
{
var result = new SessionDeletionEnqueueResult
{
@@ -86,12 +90,11 @@ public class DeleteSessionFunctionTests
ExistingState = "Running",
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
AssertProperty(body, "success", false);
AssertProperty(body, "manifestId", ManifestId);
AssertProperty(body, "deletionState", "Running");
AssertProperty(body, "hint", "cascade_already_in_flight");
Assert.Equal(Constants.ApiErrorCodes.CascadeAlreadyInFlight, body.Code);
Assert.Equal(ManifestId, body.ManifestId);
Assert.Equal("Running", body.DeletionState);
}
[Fact]
@@ -106,13 +109,12 @@ public class DeleteSessionFunctionTests
ExistingState = "Poisoned",
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
AssertProperty(body, "hint", "cascade_poisoned_use_restore");
AssertProperty(body, "manifestId", ManifestId);
Assert.Equal(Constants.ApiErrorCodes.CascadePoisonedUseRestore, body.Code);
Assert.Equal(ManifestId, body.ManifestId);
// The message string must mention the restore endpoint so the UI surfaces it verbatim.
var message = GetProperty<string>(body, "message");
Assert.Contains("/restore", message);
Assert.Contains("/restore", body.Error);
}
[Fact]
@@ -126,10 +128,27 @@ public class DeleteSessionFunctionTests
Outcome = SessionDeletionEnqueueOutcome.KillSwitchActive,
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
AssertProperty(body, "hint", "kill_switch_active");
Assert.False(HasProperty(body, "manifestId"));
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, body.Code);
Assert.Null(body.ManifestId);
Assert.DoesNotContain("manifestId", TestWire.Serialize(body));
}
[Fact]
public void BuildV2ResponseBody_rejected_arms_serialize_with_the_envelope_prefix()
{
var result = new SessionDeletionEnqueueResult
{
Outcome = SessionDeletionEnqueueOutcome.SessionNotFound,
};
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
body.CorrelationId = "cid-1";
Assert.Equal(
$"{{\"error\":\"Session {SessionId} not found\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}}",
TestWire.Serialize(body));
}
// ── EvaluateAdminDeleteGates (kill-switch short-circuit) ──────────────────────────────
@@ -141,10 +160,10 @@ public class DeleteSessionFunctionTests
Assert.NotNull(gate);
Assert.Equal(HttpStatusCode.ServiceUnavailable, gate!.Value.Status);
AssertProperty(gate.Value.Body, "hint", "kill_switch_active");
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, gate.Value.Body.Code);
// The kill-switch body must NOT carry a manifestId so the UI doesn't render a
// "track this cascade" link for a request that was refused.
Assert.False(HasProperty(gate.Value.Body, "manifestId"));
Assert.Null(gate.Value.Body.ManifestId);
}
[Fact]
@@ -157,25 +176,6 @@ public class DeleteSessionFunctionTests
Assert.Null(gate);
}
// ── 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 | 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 | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
var value = prop!.GetValue(body);
return (T)value!;
}
private static bool HasProperty(object body, string propertyName) =>
body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance) != null;
private static SessionDeletionRejectedResponse Rejected(IApiResponse body)
=> Assert.IsType<SessionDeletionRejectedResponse>(body);
}
@@ -46,12 +46,7 @@ public class DeletionManifestBuilderTests
.WithSignals(15)
.WithDecisionTransitions(4)
.WithEventTypeIndex(5)
.WithCveIndex(2)
.WithSessionsByTerminal(1)
.WithSessionsByStage(3)
.WithDeadEndsByReason(0)
.WithClassifierVerdictsByIdLevel(2)
.WithSignalsByKind(11));
.WithCveIndex(2));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -69,15 +64,10 @@ public class DeletionManifestBuilderTests
Assert.Equal(4, c["decisionTransitions"]);
Assert.Equal(5, c["eventTypeIndex"]);
Assert.Equal(2, c["cveIndex"]);
Assert.Equal(1, c["sessionsByTerminal"]);
Assert.Equal(3, c["sessionsByStage"]);
Assert.Equal(0, c["deadEndsByReason"]);
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
Assert.Equal(21, manifest.Steps.Count);
// 15 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
// SessionTenantLookup P6.1) + tombstone = 16 steps; no inventory steps when no contributions row.
Assert.Equal(16, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -99,11 +89,6 @@ public class DeletionManifestBuilderTests
.WithDecisionTransitions(1, propValue: "transitions_marker")
.WithEventTypeIndex(1, propValue: "eti_marker")
.WithCveIndex(1, propValue: "cve_marker")
.WithSessionsByTerminal(1, propValue: "terminal_marker")
.WithSessionsByStage(1, propValue: "stage_marker")
.WithDeadEndsByReason(1, propValue: "deadend_marker")
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
.WithSessionAnnotations(propValue: "annotation_marker")
.WithSessionTenantLookup(propValue: "lookup_marker"));
@@ -149,9 +134,9 @@ public class DeletionManifestBuilderTests
new DeletionRetentionContext { TenantRetentionDays = 90 });
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
Assert.Equal(20, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps
// (11 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
Assert.Equal(15, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -180,8 +165,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
Assert.Equal(23, manifest.Steps.Count);
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
Assert.Equal(18, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -310,8 +295,7 @@ public class DeletionManifestBuilderTests
.WithSessionsRow()
.WithSessionsIndexRow()
.WithEvents(3)
.WithSignals(5)
.WithSessionsByTerminal(1));
.WithSignals(5));
var first = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete",
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
@@ -345,7 +329,6 @@ public class DeletionManifestBuilderTests
.WithSessionsIndexRow()
.WithEvents(2, propValue: "events_check")
.WithRuleResults(1, propValue: "rules_check")
.WithSessionsByTerminal(1, propValue: "terminal_check")
.WithSessionInventoryContributions(MakeContributionsRow(new List<DeletionDecrementKey>
{
new DeletionDecrementKey { Vendor = "Contoso", Name = "App", Version = "1.0" },
@@ -778,12 +761,6 @@ public class DeletionManifestBuilderTests
return this;
}
public ReaderSeed WithSessionsByTerminal(int n, string propValue = "term") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByTerminal, n, propValue, "Failed");
public ReaderSeed WithSessionsByStage(int n, string propValue = "stg") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByStage, n, propValue, "AccountSetup");
public ReaderSeed WithDeadEndsByReason(int n, string propValue = "de") => SeedDiscriminatorPkProp(Constants.TableNames.DeadEndsByReason, n, propValue, "Timeout");
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
{
// One row per lane — the builder emits one exact-RK step per lane.
@@ -887,22 +864,5 @@ public class DeletionManifestBuilderTests
return this;
}
private ReaderSeed SeedDiscriminatorPkProp(string tableName, int n, string propValue, string discriminator)
{
// PK = {tenantId}_{discriminator}, server-side filter on PK prefix + SessionId == sessionId.
var rows = new List<TableEntity>(n);
for (int i = 0; i < n; i++)
{
var e = new TableEntity($"{TenantId}_{discriminator}", $"{tableName}_{i:D5}")
{
["Marker"] = propValue,
["SessionId"] = SessionId,
};
e.ETag = new ETag("0x" + tableName + i);
rows.Add(e);
}
_query[tableName] = rows;
return this;
}
}
}
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
Assert.Equal(3, totals.ExcludedSessions);
}
[Theory]
[InlineData(null, 30)] // default window
[InlineData("garbage", 30)]
[InlineData("7", 7)]
[InlineData("0", 1)] // clamped low
[InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
}
[Fact]
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
{
@@ -277,31 +277,6 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
/// <summary>
/// Inspector v1 endpoints (signals, decision-graph, reducer-verification) MUST stay locked to a
/// platform-scope tier while the UI matures (Plan §M6 — primary use case is modelling 2-stage
/// WhiteGlove deployments). They are now GlobalReadOrAdmin (GA + read-only GlobalReader): still
/// invisible to tenant admins/members, but visible to the read-only platform tier. Defense-in-depth
/// against an accidental downgrade to a tenant tier (MemberRead/TenantAdminOrGA) that would expose
/// decision internals before the lift.
///
/// When the v2 adminMode lift happens, signals+decision-graph move to MemberRead with
/// TenantScoping.QueryParam. reducer-verification stays platform-scope (never tenant-visible).
/// </summary>
[Theory]
[InlineData("GET", "/api/sessions/abc-123/signals", "sessions/{sessionId}/signals")]
[InlineData("GET", "/api/sessions/abc-123/decision-graph", "sessions/{sessionId}/decision-graph")]
[InlineData("GET", "/api/sessions/abc-123/reducer-verification", "sessions/{sessionId}/reducer-verification")]
public void InspectorRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
/// <summary>
/// SignalR group join/leave MUST be AuthenticatedUserWithRole — NOT MemberRead and NOT plain
/// AuthenticatedUser. The Progress Portal admits non-member end users, so MemberRead 403s them
@@ -27,7 +27,10 @@ public class FeatureEntitlementCatalogTests
[InlineData(" enterprise ")]
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
{
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
Assert.Equal(TenantEdition.Pro, r.Edition);
Assert.Equal(EditionSource.Plan, r.Source);
Assert.True(r.OwnPro);
}
[Theory]
@@ -38,14 +41,20 @@ public class FeatureEntitlementCatalogTests
[InlineData("premium")] // unknown value → fail-closed
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
{
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
Assert.Equal(TenantEdition.Community, r.Edition);
Assert.Equal(EditionSource.Community, r.Source);
Assert.False(r.OwnPro);
}
[Fact]
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
{
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
var r = FeatureEntitlementCatalog.Resolve("free", Now.AddSeconds(1), null, Now);
Assert.Equal(TenantEdition.Pro, r.Edition);
Assert.Equal(EditionSource.Trial, r.Source);
Assert.True(r.IsTrial);
Assert.True(r.OwnPro);
}
[Fact]
@@ -53,14 +62,14 @@ public class FeatureEntitlementCatalogTests
{
// Strict '>' — a trial ending exactly now is already over.
Assert.Equal(TenantEdition.Community,
FeatureEntitlementCatalog.ResolveEdition("free", Now, Now));
FeatureEntitlementCatalog.Resolve("free", Now, null, Now).Edition);
}
[Fact]
public void ResolveEdition_ExpiredTrial_IsCommunity()
{
Assert.Equal(TenantEdition.Community,
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
FeatureEntitlementCatalog.Resolve(null, Now.AddDays(-1), null, Now).Edition);
}
[Theory]
@@ -68,8 +77,102 @@ public class FeatureEntitlementCatalogTests
[InlineData("enterprise")]
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
{
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
var r = FeatureEntitlementCatalog.Resolve(tier, Now.AddDays(-1), null, Now);
Assert.Equal(TenantEdition.Pro, r.Edition);
Assert.Equal(EditionSource.Plan, r.Source); // permanent tier beats the stale trial timestamp
}
// ── Conferred Pro ("Pro (MSP)") ─────────────────────────────────────────────
private const string ManagingTenant = "11111111-1111-1111-1111-111111111111";
[Theory]
[InlineData("free")]
[InlineData("community")]
[InlineData(null)]
public void Resolve_ManagedByProTenant_IsPro_SourceMsp_NotOwnPro(string? tier)
{
var r = FeatureEntitlementCatalog.Resolve(tier, null, ManagingTenant, Now);
Assert.Equal(TenantEdition.Pro, r.Edition);
Assert.Equal(EditionSource.Msp, r.Source);
Assert.True(r.IsViaMsp);
Assert.False(r.IsTrial);
Assert.False(r.OwnPro);
Assert.Equal("msp", r.SourceName);
Assert.Equal("pro", r.EditionName);
}
[Theory]
[InlineData("pro", null)]
[InlineData("enterprise", null)]
[InlineData("community", 1)] // active trial
public void Resolve_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsOwnPro(string tier, int? trialDaysLeft)
{
// Display precedence: MSP wins even over the tenant's own Pro (badge "Pro (MSP)"); the own
// standing survives in OwnPro so the entitlement union keeps the delegation right.
var r = FeatureEntitlementCatalog.Resolve(tier, trialDaysLeft is int d ? Now.AddDays(d) : null, ManagingTenant, Now);
Assert.Equal(TenantEdition.Pro, r.Edition);
Assert.Equal(EditionSource.Msp, r.Source);
Assert.True(r.OwnPro);
Assert.False(r.IsTrial);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_BlankManagedBy_IsNotManaged(string managedBy)
{
var r = FeatureEntitlementCatalog.Resolve("community", null, managedBy, Now);
Assert.Equal(TenantEdition.Community, r.Edition);
Assert.Equal(EditionSource.Community, r.Source);
}
[Fact]
public void Resolve_FromConfig_ReadsTheProjection()
{
var config = new AutopilotMonitor.Shared.Models.TenantConfiguration
{
TenantId = "t", PlanTier = "community", ManagedByProTenantId = ManagingTenant,
};
Assert.Equal(EditionSource.Msp, FeatureEntitlementCatalog.Resolve(config, Now).Source);
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(config, Now));
}
[Fact]
public void Get_ConferredPro_IsProWithoutTheDelegationRight()
{
var conferred = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false));
var pro = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
Assert.Equal(TenantEdition.Pro, conferred.Edition);
Assert.False(conferred.DelegatedAdminAllowed);
Assert.Equal(0, conferred.MaxDelegatedTenants);
// Everything else is the Pro value.
Assert.Equal(pro.RetentionCapDays, conferred.RetentionCapDays);
Assert.Equal(pro.UserRateLimitPerMinute, conferred.UserRateLimitPerMinute);
Assert.Equal(pro.DeviceRateLimitPerMinute, conferred.DeviceRateLimitPerMinute);
Assert.Equal(pro.BootstrapIncluded, conferred.BootstrapIncluded);
Assert.Equal(pro.UnrestrictedModeAvailable, conferred.UnrestrictedModeAvailable);
Assert.Equal(pro.McpUsagePlanName, conferred.McpUsagePlanName);
Assert.Equal(pro.McpDailyRequestLimit, conferred.McpDailyRequestLimit);
Assert.Equal(pro.McpMonthlyRequestLimit, conferred.McpMonthlyRequestLimit);
Assert.Equal(pro.McpTenantDailyRequestLimit, conferred.McpTenantDailyRequestLimit);
Assert.Equal(pro.McpTenantMonthlyRequestLimit, conferred.McpTenantMonthlyRequestLimit);
}
[Fact]
public void Get_ManagedTenantThatIsProItself_KeepsTheFullProSet()
{
var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: true));
Assert.True(e.DelegatedAdminAllowed);
Assert.Equal(2, e.MaxDelegatedTenants);
}
[Fact]
public void Get_CommunityResolution_IsCommunity_RegardlessOfOwnProFlag()
{
var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: true));
Assert.Equal(TenantEdition.Community, e.Edition);
}
// ── IsPermanentProTier ───────────────────────────────────────────────────────
@@ -1,61 +0,0 @@
using AutopilotMonitor.Functions.Functions.Sessions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the pure helpers on <see cref="GetSessionSignalsFunction"/>. The HTTP-trigger
/// end needs a live runtime harness; this file covers the deterministic query-string
/// normalisation that decides how many rows we load.
/// </summary>
public class GetSessionSignalsFunctionTests
{
[Fact]
public void ParseMaxResults_defaults_when_query_param_missing()
{
var result = GetSessionSignalsFunction.ParseMaxResults("");
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
[Fact]
public void ParseMaxResults_defaults_when_query_param_is_garbage()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana");
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
[Fact]
public void ParseMaxResults_honours_valid_value_inside_range()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250");
Assert.Equal(250, result);
}
[Fact]
public void ParseMaxResults_caps_at_MaxResultsCap()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999");
Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result);
}
[Theory]
[InlineData("?maxResults=0")]
[InlineData("?maxResults=-5")]
public void ParseMaxResults_floors_at_one_for_non_positive_values(string query)
{
var result = GetSessionSignalsFunction.ParseMaxResults(query);
Assert.Equal(1, result);
}
[Fact]
public void ParseMaxResults_handles_null_query_string()
{
var result = GetSessionSignalsFunction.ParseMaxResults(null!);
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
}
@@ -46,6 +46,9 @@ public class GetTenantFeatureFlagsPayloadTests
"contactEmailSet",
"diagnosticsUploadConfigured",
"edition",
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
// edition applies (the "Pro (MSP)" badge), never who the managing tenant is.
"editionSource",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
"entitlements",
@@ -266,11 +269,59 @@ public class GetTenantFeatureFlagsPayloadTests
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.Equal("trial", element.GetProperty("editionSource").GetString());
Assert.True(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
}
[Fact]
public void Payload_EditionSource_Plan_And_Community()
{
Assert.Equal("plan", Serialize(new TenantConfiguration { PlanTier = "pro" }).GetProperty("editionSource").GetString());
Assert.Equal("community", Serialize(new TenantConfiguration()).GetProperty("editionSource").GetString());
}
[Fact]
public void Payload_ManagedByProTenant_IsProViaMsp_WithoutDelegationRight_NoTrialCta()
{
// The projection (set by the configuration loader) turns a Community tenant into "Pro (MSP)":
// Pro entitlements minus delegation, no trial CTA, never "isTrial", no expiry key — and the
// managing tenant's id itself is NOT part of this member-readable payload.
var element = Serialize(new TenantConfiguration
{
PlanTier = "community",
ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
});
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.Equal("msp", element.GetProperty("editionSource").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.False(element.TryGetProperty("trialExpiresUtc", out _));
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
var entitlements = element.GetProperty("entitlements");
Assert.Equal(365, entitlements.GetProperty("retentionCapDays").GetInt32());
Assert.False(entitlements.GetProperty("delegatedAdminAllowed").GetBoolean());
Assert.Equal(0, entitlements.GetProperty("maxDelegatedTenants").GetInt32());
Assert.Equal("pro", entitlements.GetProperty("mcpUsagePlan").GetString());
Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", element.GetRawText());
}
[Fact]
public void Payload_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsDelegationRight()
{
var element = Serialize(new TenantConfiguration
{
PlanTier = "pro",
ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
});
Assert.Equal("msp", element.GetProperty("editionSource").GetString());
Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
Assert.Equal(2, element.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
}
[Fact]
public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
{
@@ -75,7 +75,7 @@ public class HealthCheckServicePoisonQueueTests
var probe = new FakePoisonQueueProbe();
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 3;
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
@@ -94,7 +94,7 @@ public class HealthCheckServicePoisonQueueTests
var probe = new FakePoisonQueueProbe();
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 15;
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 2;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 2;
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
@@ -117,7 +117,7 @@ public class HealthCheckServicePoisonQueueTests
}
};
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
@@ -134,7 +134,7 @@ public class HealthCheckServicePoisonQueueTests
var probe = new FakePoisonQueueProbe();
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 4;
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
// warn=5 → 4 messages now classifies as healthy (would be warning under defaults)
var svc = BuildService(probe, new Dictionary<string, string?>
@@ -153,7 +153,7 @@ public class HealthCheckServicePoisonQueueTests
var probe = new FakePoisonQueueProbe();
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 1;
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
@@ -0,0 +1,228 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// ANALYZE-ID-004 v2.0.0 — pins the built-in that consumes the agent's user-affinity signal:
/// the real user reached the desktop (<c>desktop_arrived</c>), IME acquired no Entra user token
/// within the wait window (<c>entra_user_affinity_pending</c>, at most once per agent process),
/// and no <c>ime_user_token_acquired</c> disproves it. Repetition across reboots is the
/// confidence lever (base 60, +20 at two, threshold 75), the terminal precondition on
/// <c>enrollment_complete</c> stays the suppression at session end, and the v1.x evidence
/// (<c>hybrid_login_pending</c>, the JoinInfo placeholder) must no longer fire the rule.
/// </summary>
public class HybridUserAffinityRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "77777777-8888-9999-aaaa-bbbbbbbbbbbb";
private static AnalyzeRule Rule() => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ID-004");
[Fact]
public void ANALYZE_ID_004_v2_shape_is_pinned()
{
var rule = Rule();
Assert.Equal("2.0.0", rule.Version);
Assert.True(rule.Enabled);
Assert.Equal("high", rule.Severity);
Assert.False(rule.MarkSessionAsFailedDefault);
Assert.Equal(75, rule.ConfidenceThreshold);
Assert.Contains("on_event:entra_user_affinity_pending", rule.EvaluateOn!);
Assert.DoesNotContain("on_event:hybrid_login_pending", rule.EvaluateOn!);
// The old evidence is gone from every rule surface — trigger, conditions and factors.
Assert.DoesNotContain(rule.Conditions, c => c.EventType is "hybrid_login_pending" or "aad_placeholder_user_detected");
Assert.DoesNotContain(rule.ConfidenceFactors, f => f.Signal is "hybrid_login_pending" or "aad_placeholder_user_detected");
var noToken = Assert.Single(rule.Conditions, c => c.Signal == "no_user_token");
Assert.Equal("event_type", noToken.Source);
Assert.Equal("ime_user_token_acquired", noToken.EventType);
Assert.Equal("not_exists", noToken.Operator);
Assert.True(noToken.Required);
}
[Fact]
public async Task Fires_when_affinity_is_pending_twice_and_no_token_was_acquired()
{
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
AffinityPending(sequence: 20),
AffinityPending(sequence: 30), // second agent process after a reboot
};
var outcome = await RunAsync(Rule(), events);
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-ID-004", result.RuleId);
Assert.True(result.ConfidenceScore >= 75, $"confidence {result.ConfidenceScore} below threshold");
Assert.Equal(80, result.ConfidenceScore); // base 60 + count >= 2
// The absence condition carries its own evidence so the matched-condition map is
// complete for the UI, not just the two presence signals.
Assert.True(result.MatchedConditions.ContainsKey("user_affinity_pending"));
Assert.True(result.MatchedConditions.ContainsKey("user_desktop_reached"));
Assert.True(result.MatchedConditions.ContainsKey("no_user_token"));
}
[Fact]
public async Task Three_pending_warnings_reach_confidence_90()
{
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
AffinityPending(sequence: 20),
AffinityPending(sequence: 30),
AffinityPending(sequence: 40),
};
var outcome = await RunAsync(Rule(), events);
var result = Assert.Single(outcome.Results);
Assert.Equal(90, result.ConfidenceScore); // base 60 + 20 + 10
}
[Fact]
public async Task Does_not_fire_on_a_single_pending_warning()
{
// One agent process without a token is hybrid latency until it repeats: 60 < 75.
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
AffinityPending(sequence: 20),
};
var outcome = await RunAsync(Rule(), events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task Does_not_fire_when_a_user_token_was_acquired()
{
// A token success after the desktop disproves the diagnosis even when the warning
// repeated (e.g. the first two processes stalled, the third one got the PRT).
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
AffinityPending(sequence: 20),
AffinityPending(sequence: 30),
Event("ime_user_token_acquired", sequence: 40, new Dictionary<string, object>
{
["minutesAfterDesktop"] = 3,
["tokenFailuresBeforeSuccess"] = 4,
["isHybridJoin"] = true,
}),
};
var outcome = await RunAsync(Rule(), events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task Is_skipped_by_the_precondition_when_the_enrollment_completed()
{
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
AffinityPending(sequence: 20),
AffinityPending(sequence: 30),
Event("enrollment_complete", sequence: 40, new Dictionary<string, object>()),
};
var outcome = await RunAsync(Rule(), events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task Does_not_fire_on_the_v1_evidence_alone()
{
// hybrid_login_pending now means "no real-user desktop yet"; together with the placeholder
// it was the v1.x evidence and must not satisfy v2 — even at the old confidence-raising count.
var events = new List<EnrollmentEvent>
{
DesktopArrived(sequence: 10),
Event("hybrid_login_pending", sequence: 20, HybridLoginPendingData()),
Event("hybrid_login_pending", sequence: 30, HybridLoginPendingData()),
Event("hybrid_login_pending", sequence: 40, HybridLoginPendingData()),
Event("aad_placeholder_user_detected", sequence: 50, new Dictionary<string, object>()),
Event("aad_placeholder_user_detected", sequence: 60, new Dictionary<string, object>()),
Event("aad_placeholder_user_detected", sequence: 70, new Dictionary<string, object>()),
};
var outcome = await RunAsync(Rule(), events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task Does_not_fire_without_a_real_user_desktop()
{
// The agent only arms the detector after the desktop, but the rule must not rely on that:
// two pending warnings without desktop_arrived are not this diagnosis.
var events = new List<EnrollmentEvent>
{
AffinityPending(sequence: 20),
AffinityPending(sequence: 30),
};
var outcome = await RunAsync(Rule(), events);
Assert.Empty(outcome.Results);
}
// ===== Event builders — mirror the agent emit shapes =====
private static EnrollmentEvent DesktopArrived(int sequence) =>
Event("desktop_arrived", sequence, new Dictionary<string, object>());
private static EnrollmentEvent AffinityPending(int sequence) =>
Event("entra_user_affinity_pending", sequence, new Dictionary<string, object>
{
["delayMinutes"] = 10,
["reason"] = "no_user_token_after_desktop",
["minutesSinceDesktop"] = 10,
["tokenFailureCount"] = 4,
["tokenFailureCodes"] = "0xCAA2000C",
["placeholderActive"] = true,
["isHybridJoin"] = true,
});
private static Dictionary<string, object> HybridLoginPendingData() => new()
{
["delayMinutes"] = 10,
["reason"] = "no_real_user_desktop",
["isHybridJoin"] = true,
["realUserDesktopSeen"] = false,
["placeholderActive"] = true,
};
private static EnrollmentEvent Event(string eventType, int sequence, Dictionary<string, object> data) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = eventType,
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
Sequence = sequence,
Data = data,
};
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<HybridUserAffinityRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -0,0 +1,180 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Queue;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Ime;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Storage.Queues.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Contract of the ime-msi-archive queue function (successor of the self-polling worker):
/// merge the archiver's outcome onto the ImeVersionHistory row on EVERY attempt (also
/// failures, so operators see a stuck download), return for terminal outcomes so the host
/// deletes the message, throw for retryable ones so the host's retry → poison ladder applies,
/// re-enqueue with a delay — not drop — while ImeMsiArchivingEnabled is off, and drop
/// version-less envelopes before the archiver runs.
/// </summary>
public class ImeMsiArchiveQueueFunctionTests
{
private const string Version = "1.104.102.0";
[Fact]
public async Task Success_merges_row_and_completes()
{
var h = new Harness();
h.Archiver.Result = new ImeMsiArchiveResult(
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.Archived,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
h.Producer.Verify(p => p.EnqueueAsync(
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Retryable_failure_merges_row_and_throws_for_host_retry()
{
var h = new Harness();
h.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None));
// Row always tells the truth — the failure status IS merged before the throw.
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedDownload,
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
}
[Fact]
public async Task Permanent_failure_merges_row_and_completes()
{
var h = new Harness();
h.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
}
[Fact]
public async Task Paused_requeues_with_delay_and_skips_archiver()
{
var h = new Harness(archivingEnabled: false);
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
Assert.Equal(0, h.Archiver.Calls);
h.Producer.Verify(p => p.EnqueueAsync(
It.Is<ImeMsiArchiveEnvelope>(e => e.Version == Version),
ImeMsiArchiveQueueFunction.PauseRequeueDelay,
It.IsAny<CancellationToken>()),
Times.Once);
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
Times.Never);
}
[Fact]
public async Task Envelope_without_version_is_dropped_before_archiver()
{
var h = new Harness();
await h.Sut.ProcessAsync(Message(new ImeMsiArchiveEnvelope
{
Version = string.Empty,
EnqueuedAt = DateTime.UtcNow,
}), CancellationToken.None);
Assert.Equal(0, h.Archiver.Calls);
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
Times.Never);
h.Producer.Verify(p => p.EnqueueAsync(
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
// ============================================================ Helpers ====
private static ImeMsiArchiveEnvelope Envelope() => new()
{
Version = Version,
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
EnqueuedAt = DateTime.UtcNow,
};
private static QueueMessage Message(ImeMsiArchiveEnvelope envelope) => QueuesModelFactory.QueueMessage(
messageId: "msg-" + Guid.NewGuid().ToString("N"),
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
body: new BinaryData(JsonConvert.SerializeObject(envelope)),
dequeueCount: 1);
/// <summary>Scripted archiver — the function only consumes the classified result.</summary>
private sealed class StubArchiver : ImeMsiArchiver
{
private int _calls;
public ImeMsiArchiveResult Result { get; set; } = new(
true, Statuses.Archived, Retryable: false, null, null, null, null);
public int Calls => Volatile.Read(ref _calls);
public override Task<ImeMsiArchiveResult> ArchiveAsync(
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(Result);
}
}
private sealed class Harness
{
public StubArchiver Archiver { get; } = new();
public Mock<ISessionRepository> Repo { get; } = new();
public Mock<IImeMsiArchiveProducer> Producer { get; } = new();
public ImeMsiArchiveQueueFunction Sut { get; }
public Harness(bool archivingEnabled = true)
{
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
adminConfig.Setup(a => a.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
Sut = new ImeMsiArchiveQueueFunction(
Archiver, Repo.Object, adminConfig.Object, Producer.Object,
NullLogger<ImeMsiArchiveQueueFunction>.Instance);
}
}
}
@@ -1,261 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Queues;
using Azure.Storage.Queues.Models;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Ime;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Worker-level tests for the ime-msi-archive queue consumer (daily review 2026-08-18:
/// previously untested). The worker's contract: merge the archiver's outcome onto the
/// ImeVersionHistory row on EVERY attempt (also failures, so operators see a stuck
/// download), delete the message for terminal outcomes, rethrow for retryable ones
/// (visibility-timeout ladder), pause — not drop — while ImeMsiArchivingEnabled is off,
/// and drop version-less envelopes without invoking the archiver.
/// </summary>
public class ImeMsiArchiveQueueWorkerTests
{
private const string Version = "1.104.102.0";
[Fact]
public async Task Worker_success_merges_row_and_deletes_message()
{
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.Archived,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Worker_retryable_failure_merges_row_but_leaves_message()
{
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
// Row always tells the truth — the failure status IS merged...
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedDownload,
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.AtLeastOnce);
// ...but the message stays for the visibility-timeout retry → poison ladder.
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Worker_permanent_failure_merges_row_and_completes_message()
{
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Worker_pauses_while_archiving_disabled()
{
var harness = new Harness(archivingEnabled: false);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
// Pause, not drop: no receive, no archive attempt — the message parks in the queue.
harness.MainQueue.Verify(q => q.ReceiveMessagesAsync(
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
Times.Never);
Assert.Equal(0, harness.Archiver.Calls);
}
[Fact]
public async Task Worker_drops_envelope_without_version_before_archiver()
{
var harness = new Harness();
harness.EnqueueMessage(JsonConvert.SerializeObject(new ImeMsiArchiveEnvelope
{
Version = string.Empty,
EnqueuedAt = DateTime.UtcNow,
}));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
Assert.Equal(0, harness.Archiver.Calls);
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
Times.Never);
}
// ============================================================ Helpers ====
private static ImeMsiArchiveEnvelope Envelope() => new()
{
Version = Version,
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
EnqueuedAt = DateTime.UtcNow,
};
/// <summary>Scripted archiver — the worker only consumes the classified result.</summary>
private sealed class StubArchiver : ImeMsiArchiver
{
private int _calls;
public ImeMsiArchiveResult Result { get; set; } = new(
true, Statuses.Archived, Retryable: false, null, null, null, null);
public int Calls => Volatile.Read(ref _calls);
public override Task<ImeMsiArchiveResult> ArchiveAsync(
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(Result);
}
}
/// <summary>Mock-queue poll-loop harness, same shape as SessionDeletionWorkerTests.</summary>
private sealed class Harness
{
public Mock<QueueClient> MainQueue { get; }
public Mock<QueueClient> PoisonQueue { get; }
public StubArchiver Archiver { get; } = new();
public Mock<ISessionRepository> Repo { get; } = new();
public ImeMsiArchiveQueueWorker Sut { get; }
private readonly Queue<QueueMessage> _pendingMessages = new();
public Harness(bool archivingEnabled = true)
{
MainQueue = new Mock<QueueClient>();
PoisonQueue = new Mock<QueueClient>();
MainQueue.Setup(q => q.CreateIfNotExistsAsync(
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response?)null);
PoisonQueue.Setup(q => q.CreateIfNotExistsAsync(
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Response?)null);
MainQueue.Setup(q => q.ReceiveMessagesAsync(
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.Returns<int, TimeSpan?, CancellationToken>((maxMessages, _, _) =>
{
var batch = new List<QueueMessage>();
lock (_pendingMessages)
{
while (batch.Count < maxMessages && _pendingMessages.Count > 0)
batch.Add(_pendingMessages.Dequeue());
}
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
});
MainQueue.Setup(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Mock<Response>().Object);
MainQueue.Setup(q => q.UpdateMessageAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.Returns<string, string, string, TimeSpan, CancellationToken>((_, _, _, vis, _) =>
{
var updated = QueuesModelFactory.UpdateReceipt(
"pop-extended-" + Guid.NewGuid().ToString("N"),
DateTimeOffset.UtcNow.Add(vis));
return Task.FromResult(Response.FromValue(updated, new Mock<Response>().Object));
});
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
adminConfig.Setup(a => a.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
Sut = new ImeMsiArchiveQueueWorker(
MainQueue.Object, PoisonQueue.Object,
Archiver, Repo.Object, adminConfig.Object,
NullLogger<ImeMsiArchiveQueueWorker>.Instance,
pollInterval: TimeSpan.FromMilliseconds(50));
}
public void EnqueueMessage(string body)
{
var msg = QueuesModelFactory.QueueMessage(
messageId: "msg-" + Guid.NewGuid().ToString("N"),
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
body: new BinaryData(body),
dequeueCount: 1);
lock (_pendingMessages) _pendingMessages.Enqueue(msg);
}
public async Task RunForAsync(TimeSpan duration)
{
using var cts = new CancellationTokenSource(duration);
try { await Sut.StartAsync(cts.Token); }
catch (OperationCanceledException) { }
try { await Task.Delay(duration, cts.Token); }
catch (OperationCanceledException) { }
try { await Sut.StopAsync(CancellationToken.None); }
catch (OperationCanceledException) { }
}
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
using var cts = new CancellationTokenSource();
try { await Sut.StartAsync(cts.Token); }
catch (OperationCanceledException) { }
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(25);
cts.Cancel();
try { await Sut.StopAsync(CancellationToken.None); }
catch (OperationCanceledException) { }
}
public bool MainQueueDeleted() =>
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
}
}
@@ -1,285 +0,0 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services.Indexing;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <b>M5 release-gate</b> (Plan §M5-Pflicht line 1395): per-index-table PK/RK consistency
/// between primary rows and their derived index rows. For each index table, walk a
/// canonical primary row through the Factory + Handler pipeline and verify that the
/// captured index record carries enough back-ref information to reconstruct the
/// primary's <c>(PartitionKey, RowKey)</c> via <see cref="TableSignalRepository"/> /
/// <see cref="TableDecisionTransitionRepository"/> key builders.
/// <para>
/// Also asserts pipeline idempotency: running the same primary row through twice
/// produces identical upsert keys — the consumer's <c>UpsertReplace</c> batches stay
/// deduplicating even under timer-triggered resubmits.
/// </para>
/// </summary>
public class IndexDualWriteConsistencyTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
{
var repo = new FakeIndexTableRepository();
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
return (handler, repo);
}
// ============================================================ SignalsByKind ↔ Signals
[Fact]
public async Task SignalsByKind_row_carries_back_ref_to_primary_Signals_row()
{
var primary = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = 42,
Kind = "EspTerminalFailure",
OccurredAtUtc = Occurred,
SourceOrigin = "EspAndHelloTrackerAdapter",
};
var (handler, repo) = Sut();
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(primary));
var index = Assert.Single(repo.SignalsByKind);
// Back-ref fields on the index row must reconstruct the primary PK/RK.
var reconstructedPk = TableSignalRepository.BuildPartitionKey(index.TenantId, index.SessionId);
var reconstructedRk = TableSignalRepository.BuildRowKey(index.SessionSignalOrdinal);
Assert.Equal(
TableSignalRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
reconstructedPk);
Assert.Equal(
TableSignalRepository.BuildRowKey(primary.SessionSignalOrdinal),
reconstructedRk);
}
// ============================================================ SessionsByTerminal ↔ DecisionTransitions
[Fact]
public async Task SessionsByTerminal_row_carries_back_ref_to_primary_DecisionTransitions_row()
{
var primary = TerminalTransition();
var (handler, repo) = Sut();
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
var index = Assert.Single(repo.SessionsByTerminal);
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
Assert.Equal(
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
reconstructedPk);
Assert.Equal(
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
reconstructedRk);
}
[Fact]
public async Task SessionsByStage_row_carries_back_ref_to_primary_DecisionTransitions_row()
{
var primary = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 3,
FromStage = "AwaitingEsp",
ToStage = "EspInProgress",
Taken = true,
IsTerminal = false,
OccurredAtUtc = Occurred,
};
var (handler, repo) = Sut();
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
var index = Assert.Single(repo.SessionsByStage);
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
Assert.Equal(
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
reconstructedPk);
Assert.Equal(
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
reconstructedRk);
}
[Fact]
public async Task DeadEndsByReason_row_carries_back_ref_to_primary_DecisionTransitions_row()
{
var primary = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 5,
FromStage = "EspInProgress",
ToStage = "EspInProgress",
Taken = false,
IsTerminal = false,
DeadEndReason = "hybrid_reboot_gate_blocking",
OccurredAtUtc = Occurred,
};
var (handler, repo) = Sut();
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
var index = Assert.Single(repo.DeadEndsByReason);
Assert.Equal(
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
Assert.Equal(
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
}
[Fact]
public async Task ClassifierVerdictsByIdLevel_row_carries_back_ref_to_primary_DecisionTransitions_row()
{
var primary = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 7,
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
OccurredAtUtc = Occurred,
};
var (handler, repo) = Sut();
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
var index = Assert.Single(repo.ClassifierVerdictsByIdLevel);
Assert.Equal(
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
Assert.Equal(
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
}
// ============================================================ pipeline idempotency
[Fact]
public async Task Resubmitting_same_primary_rows_produces_same_index_keys()
{
// Simulates the M5.d.4 timer re-enqueueing primary rows it already handled —
// the handler's index-row keys must be key-stable so the real TableIndexRepository's
// UpsertReplace collapses duplicates rather than appending rows.
var (handler, repo) = Sut();
var signal = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = 1,
Kind = "DesktopArrived",
OccurredAtUtc = Occurred,
};
var transition = TerminalTransition();
// First pass: initial ingest fan-out.
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
// Second pass: 2h-timer reconcile resubmits the same primaries.
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
// The fake repo captures one row per call, but all duplicated rows must map to the
// same (PK, RK) — the real Azure Tables Upsert would collapse them.
Assert.Equal(2, repo.SignalsByKind.Count);
var signalKeys = repo.SignalsByKind
.Select(r => (
Pk: IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind),
Rk: IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal)))
.Distinct()
.ToList();
Assert.Single(signalKeys);
Assert.Equal(2, repo.SessionsByTerminal.Count);
var terminalKeys = repo.SessionsByTerminal
.Select(r => (
Pk: IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage),
Rk: IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId)))
.Distinct()
.ToList();
Assert.Single(terminalKeys);
Assert.Equal(2, repo.SessionsByStage.Count);
var stageKeys = repo.SessionsByStage
.Select(r => (
Pk: IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage),
Rk: IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId)))
.Distinct()
.ToList();
Assert.Single(stageKeys);
Assert.Equal(2, repo.ClassifierVerdictsByIdLevel.Count);
var verdictKeys = repo.ClassifierVerdictsByIdLevel
.Select(r => (
Pk: IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel),
Rk: IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex)))
.Distinct()
.ToList();
Assert.Single(verdictKeys);
}
// ============================================================ helpers
private static DecisionTransitionRecord TerminalTransition()
=> new()
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 7,
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
OccurredAtUtc = Occurred,
};
private sealed class FakeIndexTableRepository : IIndexTableRepository
{
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
public Task<int> StoreSessionsByTerminalAsync(IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken ct = default)
{ SessionsByTerminal.AddRange(records); return Task.FromResult(records.Count); }
public Task<int> StoreSessionsByStageAsync(IReadOnlyList<SessionsByStageRecord> records, CancellationToken ct = default)
{ SessionsByStage.AddRange(records); return Task.FromResult(records.Count); }
public Task<int> StoreDeadEndsByReasonAsync(IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken ct = default)
{ DeadEndsByReason.AddRange(records); return Task.FromResult(records.Count); }
public Task<int> StoreClassifierVerdictsByIdLevelAsync(IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken ct = default)
{ ClassifierVerdictsByIdLevel.AddRange(records); return Task.FromResult(records.Count); }
public Task<int> StoreSignalsByKindAsync(IReadOnlyList<SignalsByKindRecord> records, CancellationToken ct = default)
{ SignalsByKind.AddRange(records); return Task.FromResult(records.Count); }
}
}
@@ -1,167 +0,0 @@
using AutopilotMonitor.Functions.Services.Indexing;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pure tests for <see cref="IndexReconcileEnvelopeFactory"/> (Plan §2.8, §M5.d).
/// Verifies the shape-only projection from primary record → queue envelope — the
/// consumer (M5.d.3) relies on these fields being copied verbatim.
/// </summary>
public class IndexReconcileEnvelopeFactoryTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
// ============================================================ FromSignal
[Fact]
public void FromSignal_copies_discriminators_and_back_ref()
{
var record = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = 42,
SessionTraceOrdinal = 117,
Kind = "EspTerminalFailure",
KindSchemaVersion = 2,
OccurredAtUtc = Occurred,
SourceOrigin = "EspAndHelloTrackerAdapter",
PayloadJson = "{}",
};
var envelope = IndexReconcileEnvelopeFactory.FromSignal(record);
Assert.Equal("1", envelope.EnvelopeVersion);
Assert.Equal("Signal", envelope.SourceKind);
Assert.Equal(TenantId, envelope.TenantId);
Assert.Equal(SessionId, envelope.SessionId);
Assert.Equal(Occurred, envelope.OccurredAtUtc);
Assert.Equal(42L, envelope.SessionSignalOrdinal);
Assert.Equal("EspTerminalFailure", envelope.SignalKind);
Assert.Equal("EspAndHelloTrackerAdapter", envelope.SourceOrigin);
// DecisionTransition-only fields stay null on Signal envelopes.
Assert.Null(envelope.StepIndex);
Assert.Null(envelope.FromStage);
Assert.Null(envelope.ToStage);
Assert.Null(envelope.Taken);
Assert.Null(envelope.IsTerminal);
Assert.Null(envelope.DeadEndReason);
Assert.Null(envelope.ClassifierVerdictId);
Assert.Null(envelope.ClassifierHypothesisLevel);
}
// ============================================================ FromDecisionTransition
[Fact]
public void FromDecisionTransition_taken_terminal_with_classifier_copies_all_discriminators()
{
var record = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 7,
SessionTraceOrdinal = 118,
SignalOrdinalRef = 42,
OccurredAtUtc = Occurred,
Trigger = "EspExiting",
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
DeadEndReason = null,
ReducerVersion = "1.0.0",
IsTerminal = true,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
PayloadJson = "{}",
};
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
Assert.Equal("1", envelope.EnvelopeVersion);
Assert.Equal("DecisionTransition", envelope.SourceKind);
Assert.Equal(TenantId, envelope.TenantId);
Assert.Equal(SessionId, envelope.SessionId);
Assert.Equal(Occurred, envelope.OccurredAtUtc);
Assert.Equal(7, envelope.StepIndex);
Assert.Equal("EspInProgress", envelope.FromStage);
Assert.Equal("WhiteGloveSealed", envelope.ToStage);
Assert.Equal(true, envelope.Taken);
Assert.Equal(true, envelope.IsTerminal);
Assert.Null(envelope.DeadEndReason);
Assert.Equal("whiteglove-sealing", envelope.ClassifierVerdictId);
Assert.Equal("Strong", envelope.ClassifierHypothesisLevel);
// Signal-only fields stay null on DecisionTransition envelopes.
Assert.Null(envelope.SessionSignalOrdinal);
Assert.Null(envelope.SignalKind);
Assert.Null(envelope.SourceOrigin);
}
[Fact]
public void FromDecisionTransition_dead_end_carries_reason_without_classifier_fields()
{
var record = new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = 3,
OccurredAtUtc = Occurred,
Trigger = "EspExiting",
FromStage = "EspInProgress",
ToStage = "EspInProgress",
Taken = false,
DeadEndReason = "hybrid_reboot_gate_blocking",
ReducerVersion = "1.0.0",
IsTerminal = false,
PayloadJson = "{}",
};
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
Assert.Equal(false, envelope.Taken);
Assert.Equal(false, envelope.IsTerminal);
Assert.Equal("hybrid_reboot_gate_blocking", envelope.DeadEndReason);
Assert.Null(envelope.ClassifierVerdictId);
Assert.Null(envelope.ClassifierHypothesisLevel);
}
// ============================================================ BuildBatch
[Fact]
public void BuildBatch_preserves_input_order_and_kind_tags()
{
var signals = new List<SignalRecord>
{
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Occurred },
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Occurred },
};
var transitions = new List<DecisionTransitionRecord>
{
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, FromStage = "A", ToStage = "B", Taken = true, OccurredAtUtc = Occurred },
};
var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions);
Assert.Equal(3, envelopes.Count);
Assert.Equal("Signal", envelopes[0].SourceKind);
Assert.Equal(1L, envelopes[0].SessionSignalOrdinal);
Assert.Equal("Signal", envelopes[1].SourceKind);
Assert.Equal(2L, envelopes[1].SessionSignalOrdinal);
Assert.Equal("DecisionTransition", envelopes[2].SourceKind);
Assert.Equal(1, envelopes[2].StepIndex);
}
[Fact]
public void BuildBatch_returns_empty_for_null_or_empty_inputs()
{
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(
new List<SignalRecord>(), new List<DecisionTransitionRecord>()));
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(null!, null!));
}
}
@@ -1,102 +0,0 @@
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// JSON round-trip tests for <see cref="IndexReconcileEnvelope"/> (Plan §2.8, §M5.d).
/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer
/// (M5.d.3) must deserialize the same shape back — verified here so the contract
/// doesn't drift silently as fields are added.
/// </summary>
public class IndexReconcileEnvelopeSerializationTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
[Fact]
public void Signal_envelope_round_trips_through_newtonsoft()
{
var original = new IndexReconcileEnvelope
{
EnvelopeVersion = "1",
SourceKind = "Signal",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
SessionSignalOrdinal = 42,
SignalKind = "EspTerminalFailure",
SourceOrigin = "EspAndHelloTrackerAdapter",
};
var json = JsonConvert.SerializeObject(original);
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
Assert.NotNull(restored);
Assert.Equal("1", restored!.EnvelopeVersion);
Assert.Equal("Signal", restored.SourceKind);
Assert.Equal(TenantId, restored.TenantId);
Assert.Equal(SessionId, restored.SessionId);
Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime());
Assert.Equal(42L, restored.SessionSignalOrdinal);
Assert.Equal("EspTerminalFailure", restored.SignalKind);
Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin);
Assert.Null(restored.StepIndex);
Assert.Null(restored.ClassifierVerdictId);
}
[Fact]
public void DecisionTransition_envelope_round_trips_all_discriminators()
{
var original = new IndexReconcileEnvelope
{
EnvelopeVersion = "1",
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 7,
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
DeadEndReason = null,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
};
var json = JsonConvert.SerializeObject(original);
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
Assert.NotNull(restored);
Assert.Equal("DecisionTransition", restored!.SourceKind);
Assert.Equal(7, restored.StepIndex);
Assert.Equal("EspInProgress", restored.FromStage);
Assert.Equal("WhiteGloveSealed", restored.ToStage);
Assert.Equal(true, restored.Taken);
Assert.Equal(true, restored.IsTerminal);
Assert.Null(restored.DeadEndReason);
Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId);
Assert.Equal("Strong", restored.ClassifierHypothesisLevel);
Assert.Null(restored.SessionSignalOrdinal);
}
[Fact]
public void Deserialization_tolerates_missing_optional_fields()
{
// A minimal envelope from a consumer-compatible-but-older writer should still parse.
const string minimal =
"{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}";
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(minimal);
Assert.NotNull(restored);
Assert.Equal("Signal", restored!.SourceKind);
Assert.Null(restored.SessionSignalOrdinal);
Assert.Null(restored.SignalKind);
Assert.Null(restored.StepIndex);
}
}
@@ -1,321 +0,0 @@
using AutopilotMonitor.Functions.Services.Indexing;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Routing tests for <see cref="IndexReconcileHandler"/> (Plan §2.8, §M5.d.3). Verifies
/// the 0–3 fan-out decisions per envelope shape using a capture-only fake repo —
/// the real <c>TableIndexRepository</c> mapping is covered by <see cref="TableIndexRepositoryMappingTests"/>.
/// </summary>
public class IndexReconcileHandlerTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
{
var repo = new FakeIndexTableRepository();
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
return (handler, repo);
}
// ============================================================ Signal routing
[Fact]
public async Task Signal_envelope_writes_exactly_one_SignalsByKind_row()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "Signal",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
SessionSignalOrdinal = 42,
SignalKind = "EspTerminalFailure",
SourceOrigin = "EspAndHelloTrackerAdapter",
});
Assert.Single(repo.SignalsByKind);
var row = repo.SignalsByKind[0];
Assert.Equal(TenantId, row.TenantId);
Assert.Equal(SessionId, row.SessionId);
Assert.Equal("EspTerminalFailure", row.SignalKind);
Assert.Equal(42L, row.SessionSignalOrdinal);
Assert.Equal("EspAndHelloTrackerAdapter", row.SourceOrigin);
Assert.Equal(Occurred, row.OccurredAtUtc);
// Other tables untouched.
Assert.Empty(repo.SessionsByTerminal);
Assert.Empty(repo.SessionsByStage);
Assert.Empty(repo.DeadEndsByReason);
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
}
[Fact]
public async Task Signal_envelope_missing_ordinal_is_dropped()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "Signal",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
SessionSignalOrdinal = null,
SignalKind = "EspPhaseChanged",
});
Assert.Empty(repo.SignalsByKind);
}
[Fact]
public async Task Signal_envelope_missing_kind_is_dropped()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "Signal",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
SessionSignalOrdinal = 1,
SignalKind = string.Empty,
});
Assert.Empty(repo.SignalsByKind);
}
// ============================================================ DecisionTransition routing — all 4 paths
[Fact]
public async Task DecisionTransition_terminal_taken_with_classifier_writes_three_index_rows()
{
var (handler, repo) = Sut();
// WhiteGlove-sealing: Taken=true, IsTerminal=true, classifier verdict attached.
// Expected: SessionsByTerminal + SessionsByStage + ClassifierVerdictsByIdLevel (3 rows).
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 7,
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
});
Assert.Single(repo.SessionsByTerminal);
Assert.Equal("WhiteGloveSealed", repo.SessionsByTerminal[0].TerminalStage);
Assert.Equal(7, repo.SessionsByTerminal[0].StepIndex);
Assert.Single(repo.SessionsByStage);
Assert.Equal("WhiteGloveSealed", repo.SessionsByStage[0].Stage);
Assert.Equal(Occurred, repo.SessionsByStage[0].LastUpdatedUtc);
Assert.Single(repo.ClassifierVerdictsByIdLevel);
Assert.Equal("whiteglove-sealing", repo.ClassifierVerdictsByIdLevel[0].ClassifierId);
Assert.Equal("Strong", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
// Dead-end path not triggered for Taken=true.
Assert.Empty(repo.DeadEndsByReason);
// Signal path never touched on a DecisionTransition envelope.
Assert.Empty(repo.SignalsByKind);
}
[Fact]
public async Task DecisionTransition_taken_non_terminal_no_classifier_writes_only_SessionsByStage()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 3,
FromStage = "AwaitingEsp",
ToStage = "EspInProgress",
Taken = true,
IsTerminal = false,
});
Assert.Empty(repo.SessionsByTerminal);
Assert.Single(repo.SessionsByStage);
Assert.Empty(repo.DeadEndsByReason);
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
}
[Fact]
public async Task DecisionTransition_dead_end_with_classifier_writes_DeadEndsByReason_and_verdict()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 5,
FromStage = "EspInProgress",
ToStage = "EspInProgress",
Taken = false,
IsTerminal = false,
DeadEndReason = "hybrid_reboot_gate_blocking",
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Weak",
});
Assert.Single(repo.DeadEndsByReason);
Assert.Equal("hybrid_reboot_gate_blocking", repo.DeadEndsByReason[0].DeadEndReason);
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].FromStage);
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].AttemptedToStage);
Assert.Single(repo.ClassifierVerdictsByIdLevel);
Assert.Equal("Weak", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
// Taken=false → SessionsByStage is NOT updated (no stage entry).
Assert.Empty(repo.SessionsByStage);
Assert.Empty(repo.SessionsByTerminal);
}
[Fact]
public async Task DecisionTransition_dead_end_without_reason_writes_nothing_to_DeadEnds()
{
// Defense in depth: an envelope with Taken=false but no reason should not create a
// DeadEnd row (empty reason would collide into a single PK across unrelated blocks).
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 1,
FromStage = "A",
ToStage = "A",
Taken = false,
DeadEndReason = null,
});
Assert.Empty(repo.DeadEndsByReason);
}
[Fact]
public async Task DecisionTransition_missing_stepIndex_is_dropped_entirely()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = null,
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
});
Assert.Empty(repo.SessionsByTerminal);
Assert.Empty(repo.SessionsByStage);
Assert.Empty(repo.DeadEndsByReason);
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
}
// ============================================================ Misc
[Fact]
public async Task Unknown_SourceKind_is_logged_and_dropped()
{
var (handler, repo) = Sut();
await handler.HandleAsync(new IndexReconcileEnvelope
{
SourceKind = "FutureThingWeDontKnowAbout",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
});
Assert.Empty(repo.SignalsByKind);
Assert.Empty(repo.SessionsByTerminal);
Assert.Empty(repo.SessionsByStage);
Assert.Empty(repo.DeadEndsByReason);
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
}
[Fact]
public async Task Null_envelope_is_logged_and_dropped()
{
var (handler, repo) = Sut();
await handler.HandleAsync(null!);
Assert.Empty(repo.SignalsByKind);
}
// ============================================================ FakeIndexTableRepository
private sealed class FakeIndexTableRepository : IIndexTableRepository
{
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
public Task<int> StoreSessionsByTerminalAsync(
IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken cancellationToken = default)
{
SessionsByTerminal.AddRange(records);
return Task.FromResult(records.Count);
}
public Task<int> StoreSessionsByStageAsync(
IReadOnlyList<SessionsByStageRecord> records, CancellationToken cancellationToken = default)
{
SessionsByStage.AddRange(records);
return Task.FromResult(records.Count);
}
public Task<int> StoreDeadEndsByReasonAsync(
IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken cancellationToken = default)
{
DeadEndsByReason.AddRange(records);
return Task.FromResult(records.Count);
}
public Task<int> StoreClassifierVerdictsByIdLevelAsync(
IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken cancellationToken = default)
{
ClassifierVerdictsByIdLevel.AddRange(records);
return Task.FromResult(records.Count);
}
public Task<int> StoreSignalsByKindAsync(
IReadOnlyList<SignalsByKindRecord> records, CancellationToken cancellationToken = default)
{
SignalsByKind.AddRange(records);
return Task.FromResult(records.Count);
}
}
}
@@ -1,156 +0,0 @@
using AutopilotMonitor.Functions.Functions.Admin;
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>
/// Tests for the 2h safety-net <see cref="IndexReconcileTimer"/> (Plan §2.8, §M5.d.4).
/// Covers the flag-gate short-circuit, the happy path (enqueue every primary row
/// in the window), and the empty-window case.
/// </summary>
public class IndexReconcileTimerTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Cutoff = new(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
// ============================================================ flag-off short-circuit
[Fact]
public async Task Feature_flag_off_skips_queries_and_enqueue()
{
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: false);
var signalRepo = new Mock<ISignalRepository>(MockBehavior.Strict);
var transitionRepo = new Mock<IDecisionTransitionRepository>(MockBehavior.Strict);
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
var timer = new IndexReconcileTimer(
adminConfigSvc,
signalRepo.Object,
transitionRepo.Object,
producer.Object,
NullLogger<IndexReconcileTimer>.Instance);
await timer.RunReconcileAsync(Cutoff, default);
// Strict mocks throw if any unconfigured call happens — so zero setups + no throw
// proves the timer short-circuited before any query / enqueue.
signalRepo.VerifyNoOtherCalls();
transitionRepo.VerifyNoOtherCalls();
producer.VerifyNoOtherCalls();
}
// ============================================================ happy path
[Fact]
public async Task Flag_on_with_primary_rows_enqueues_all_envelopes()
{
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
var signals = new List<SignalRecord>
{
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Cutoff.AddMinutes(5) },
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Cutoff.AddMinutes(10) },
};
var transitions = new List<DecisionTransitionRecord>
{
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, Taken = true, FromStage = "A", ToStage = "B", OccurredAtUtc = Cutoff.AddMinutes(7) },
};
var signalRepo = new Mock<ISignalRepository>();
signalRepo
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(signals);
var transitionRepo = new Mock<IDecisionTransitionRepository>();
transitionRepo
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(transitions);
List<IndexReconcileEnvelope>? captured = null;
var producer = new Mock<IIndexReconcileProducer>();
producer
.Setup(p => p.EnqueueBatchAsync(It.IsAny<IReadOnlyList<IndexReconcileEnvelope>>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyList<IndexReconcileEnvelope>, CancellationToken>((e, _) => captured = e.ToList())
.ReturnsAsync(3);
var timer = new IndexReconcileTimer(
adminConfigSvc,
signalRepo.Object,
transitionRepo.Object,
producer.Object,
NullLogger<IndexReconcileTimer>.Instance);
await timer.RunReconcileAsync(Cutoff, default);
Assert.NotNull(captured);
Assert.Equal(3, captured!.Count);
Assert.Equal("Signal", captured[0].SourceKind);
Assert.Equal(1L, captured[0].SessionSignalOrdinal);
Assert.Equal("Signal", captured[1].SourceKind);
Assert.Equal(2L, captured[1].SessionSignalOrdinal);
Assert.Equal("DecisionTransition", captured[2].SourceKind);
Assert.Equal(1, captured[2].StepIndex);
}
// ============================================================ empty-window: no enqueue
[Fact]
public async Task Flag_on_empty_window_skips_enqueue()
{
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
var signalRepo = new Mock<ISignalRepository>();
signalRepo
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<SignalRecord>());
var transitionRepo = new Mock<IDecisionTransitionRepository>();
transitionRepo
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DecisionTransitionRecord>());
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
var timer = new IndexReconcileTimer(
adminConfigSvc,
signalRepo.Object,
transitionRepo.Object,
producer.Object,
NullLogger<IndexReconcileTimer>.Instance);
await timer.RunReconcileAsync(Cutoff, default);
// Empty batch → no producer call (strict mock throws otherwise).
producer.VerifyNoOtherCalls();
}
// ============================================================ cutoff window invariant
[Fact]
public void ReconcileWindow_is_4_hours_and_larger_than_timer_cadence()
{
// The 2h cron fires every 2h but scans a 4h window — so every primary row is
// re-checked at least twice before falling out of range. Protect the invariant.
Assert.Equal(TimeSpan.FromHours(4), IndexReconcileTimer.ReconcileWindow);
}
// ============================================================ helpers
private static AdminConfigurationService BuildAdminConfigService(bool enableIndexDualWrite)
{
var mock = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
mock.Setup(s => s.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { EnableIndexDualWrite = enableIndexDualWrite });
return mock.Object;
}
}
@@ -1,153 +0,0 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pure PK/RK format tests for <see cref="IndexRowKeys"/> (Plan §2.8 query matrix, §M5.d).
/// These verify the string shape contract the queue-driven index writer + the reconciliation
/// timer both depend on — any drift here silently breaks cross-session queries.
/// </summary>
public class IndexRowKeysTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
// ============================================================ SessionsByTerminal
[Fact]
public void SessionsByTerminal_pk_is_tenant_underscore_terminalStage()
{
Assert.Equal(
$"{TenantId}_WhiteGloveSealed",
IndexRowKeys.BuildSessionsByTerminalPk(TenantId, "WhiteGloveSealed"));
}
[Fact]
public void SessionsByTerminal_rk_is_yyyyMMddHHmmss_underscore_sessionId()
{
Assert.Equal(
$"20260421140733_{SessionId}",
IndexRowKeys.BuildSessionsByTerminalRk(Occurred, SessionId));
}
// ============================================================ SessionsByStage
[Fact]
public void SessionsByStage_pk_is_tenant_underscore_stage()
{
Assert.Equal(
$"{TenantId}_EspInProgress",
IndexRowKeys.BuildSessionsByStagePk(TenantId, "EspInProgress"));
}
[Fact]
public void SessionsByStage_rk_pads_ticks_to_19_digits_for_lex_ordering()
{
var rk = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
// 19-digit zero-padding is the contract — string comparison then matches numeric time order.
var parts = rk.Split('_');
Assert.Equal(19, parts[0].Length);
Assert.Equal(Occurred.Ticks.ToString("D19"), parts[0]);
Assert.Equal(SessionId, parts[1]);
}
[Fact]
public void SessionsByStage_rk_orders_lexicographically_matching_chronology()
{
var earlier = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
var later = IndexRowKeys.BuildSessionsByStageRk(Occurred.AddSeconds(1), SessionId);
Assert.True(string.CompareOrdinal(earlier, later) < 0);
}
// ============================================================ DeadEndsByReason
[Fact]
public void DeadEndsByReason_pk_is_tenant_underscore_reason()
{
Assert.Equal(
$"{TenantId}_hybrid_reboot_gate_blocking",
IndexRowKeys.BuildDeadEndsByReasonPk(TenantId, "hybrid_reboot_gate_blocking"));
}
[Fact]
public void DeadEndsByReason_rk_combines_timestamp_session_stepIndex_padded_to_6()
{
Assert.Equal(
$"20260421140733_{SessionId}_000042",
IndexRowKeys.BuildDeadEndsByReasonRk(Occurred, SessionId, 42));
}
// ============================================================ ClassifierVerdictsByIdLevel
[Fact]
public void ClassifierVerdictsByIdLevel_pk_is_tenant_underscore_classifierId_underscore_level()
{
Assert.Equal(
$"{TenantId}_whiteglove-sealing_Weak",
IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(TenantId, "whiteglove-sealing", "Weak"));
}
[Fact]
public void ClassifierVerdictsByIdLevel_rk_combines_timestamp_session_stepIndex_padded_to_6()
{
Assert.Equal(
$"20260421140733_{SessionId}_000007",
IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(Occurred, SessionId, 7));
}
// ============================================================ SignalsByKind
[Fact]
public void SignalsByKind_pk_is_tenant_underscore_signalKind()
{
Assert.Equal(
$"{TenantId}_EspTerminalFailure",
IndexRowKeys.BuildSignalsByKindPk(TenantId, "EspTerminalFailure"));
}
[Fact]
public void SignalsByKind_rk_combines_timestamp_session_ordinal_padded_to_10()
{
Assert.Equal(
$"20260421140733_{SessionId}_0000000123",
IndexRowKeys.BuildSignalsByKindRk(Occurred, SessionId, 123));
}
// ============================================================ UTC coercion + sanitizing
[Fact]
public void Timestamp_always_normalizes_to_UTC()
{
// If a caller passes Local-kind DateTime, conversion must still produce the UTC wall clock.
var localKind = DateTime.SpecifyKind(Occurred, DateTimeKind.Local);
var rk = IndexRowKeys.BuildSessionsByTerminalRk(localKind, SessionId);
Assert.Equal($"{localKind.ToUniversalTime():yyyyMMddHHmmss}_{SessionId}", rk);
}
[Theory]
[InlineData("foo/bar", "foo_bar")]
[InlineData("foo\\bar", "foo_bar")]
[InlineData("foo#bar", "foo_bar")]
[InlineData("foo?bar", "foo_bar")]
[InlineData("clean_name", "clean_name")]
[InlineData("", "")]
public void Sanitize_replaces_forbidden_key_chars_with_underscore(string input, string expected)
{
Assert.Equal(expected, IndexRowKeys.Sanitize(input));
}
[Fact]
public void Sanitize_preserves_enum_style_identifiers_unchanged()
{
// Discriminators are overwhelmingly enum names — sanitize must not mangle them.
Assert.Equal("WhiteGloveSealed", IndexRowKeys.Sanitize("WhiteGloveSealed"));
Assert.Equal("EspTerminalFailure", IndexRowKeys.Sanitize("EspTerminalFailure"));
Assert.Equal("hybrid_reboot_gate_blocking", IndexRowKeys.Sanitize("hybrid_reboot_gate_blocking"));
}
}
@@ -641,6 +641,10 @@ public class InfrastructureWireParityTests
}
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
// DELIBERATE wire change (error-envelope pass): the body now leads with the envelope prefix
// error/code/correlationId (error carries the former `message`); quotaExceeded stays as the
// MCP's discriminator. These literals pin the NEW shape (correlationId is stamped by the writer,
// so the builder leaves it empty).
[Fact]
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
@@ -654,14 +658,16 @@ public class InfrastructureWireParityTests
AssertParity(
new
{
error = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z.",
code = "QuotaExceeded",
correlationId = "",
quotaExceeded = true,
plan = "community",
scope = "daily",
level = "user",
limit = 200,
used = 200L,
resetUtc = "2026-08-31T00:00:00Z",
message = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z."
resetUtc = "2026-08-31T00:00:00Z"
},
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
}
@@ -679,14 +685,16 @@ public class InfrastructureWireParityTests
AssertParity(
new
{
error = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z.",
code = "QuotaExceeded",
correlationId = "",
quotaExceeded = true,
plan = "power",
scope = "monthly",
level = "tenant",
limit = 60000,
used = 60000L,
resetUtc = "2026-09-01T00:00:00Z",
message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z."
resetUtc = "2026-09-01T00:00:00Z"
},
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
}
@@ -701,17 +709,20 @@ public class InfrastructureWireParityTests
AssertParity(
new
{
error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
code = "QuotaExceeded",
correlationId = "",
quotaExceeded = true,
plan = "community",
scope,
level = "user",
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."
resetUtc = "2026-08-31T00:00:00Z"
},
new McpQuotaExceededResponse
{
Error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
QuotaExceeded = true,
Plan = "community",
Scope = null,
@@ -719,7 +730,6 @@ public class InfrastructureWireParityTests
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."
});
}
@@ -737,6 +747,9 @@ public class InfrastructureWireParityTests
AssertParity(
new
{
error = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
code = "QuotaExceeded",
correlationId = "",
quotaExceeded = true,
plan = "pro",
scope = "daily",
@@ -744,7 +757,6 @@ public class InfrastructureWireParityTests
limit = 300,
used = 300L,
resetUtc = "2026-08-31T00:00:00Z",
message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"
},
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example"));
@@ -763,14 +775,16 @@ public class InfrastructureWireParityTests
AssertParity(
new
{
error = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z.",
code = "QuotaExceeded",
correlationId = "",
quotaExceeded = true,
plan = "pro",
scope = "daily",
level = "tenant",
limit = 300,
used = 300L,
resetUtc = "2026-08-31T00:00:00Z",
message = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z."
resetUtc = "2026-08-31T00:00:00Z"
},
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, exhaustedTenantCount: 2));
}
@@ -0,0 +1,54 @@
using System.Net;
using Azure;
using AutopilotMonitor.Functions.Functions.Ingest;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent's uploader halves its batch only on 413 and treats every 5xx as "replay later";
/// <see cref="IngestTelemetryFunction.ClassifyStorageFailure"/> is what keeps an oversize
/// transaction from surfacing as a 500 the device would retry forever.
/// </summary>
public class IngestStorageFailureClassificationTests
{
private static RequestFailedException Failure(int status, string? code = null)
=> new RequestFailedException(status, "storage", code, null);
[Fact]
public void Oversize_transaction_becomes_413_without_retry_after()
{
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(413, "RequestBodyTooLarge"));
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
Assert.Null(retryAfter);
}
[Fact]
public void Oversize_reported_as_400_with_size_code_still_becomes_413()
{
var (status, _, _) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(400, "EntityTooLarge"));
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
}
[Theory]
[InlineData(503)]
[InlineData(429)]
[InlineData(500)]
public void Transient_storage_failure_becomes_503_with_retry_after(int storageStatus)
{
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus));
Assert.Equal(HttpStatusCode.ServiceUnavailable, status);
Assert.Equal(IngestTelemetryFunction.StorageRetryAfterSeconds, retryAfter);
}
[Theory]
[InlineData(404)]
[InlineData(409)]
[InlineData(400)]
public void Anything_else_stays_500(int storageStatus)
{
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus, "InvalidInput"));
Assert.Equal(HttpStatusCode.InternalServerError, status);
Assert.Null(retryAfter);
}
}
@@ -0,0 +1,182 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the reverse index behind conferred Pro (<see cref="ManagedTenantProIndex"/>): only OWNED groups
/// of a PERMANENT-Pro owner confer; a trial owner and operator-created groups never do; the owner's tier is
/// read raw (two tenants managing each other resolve without recursion); storage failure yields "nobody is
/// managed" (fail-closed) without throwing; Invalidate forces a rebuild.
/// </summary>
public sealed class ManagedTenantProIndexTests
{
private const string Msp = "11111111-1111-1111-1111-111111111111";
private const string TrialMsp = "22222222-2222-2222-2222-222222222222";
private const string CustomerA = "33333333-3333-3333-3333-333333333333";
private const string CustomerB = "44444444-4444-4444-4444-444444444444";
private const string OperatorManaged = "55555555-5555-5555-5555-555555555555";
private static TenantGroup Owned(string owner, params string[] tenants) => new()
{
GroupId = AutopilotMonitor.Shared.Constants.TenantGroupIds.ForHomeTenant(owner),
Name = "Customers",
OwnerTenantId = owner,
TenantIds = tenants.ToList(),
};
private static TenantGroup Operator(params string[] tenants) => new()
{
GroupId = "operator-group",
Name = "Operator bundle",
OwnerTenantId = null,
TenantIds = tenants.ToList(),
};
private static (ManagedTenantProIndex Index, Mock<IAdminRepository> Admin, Mock<IConfigRepository> Config) Build(
IEnumerable<TenantGroup> groups, IDictionary<string, TenantConfiguration> configs)
{
var admin = new Mock<IAdminRepository>();
admin.Setup(a => a.GetAllTenantGroupsAsync()).ReturnsAsync(groups.ToList());
var config = new Mock<IConfigRepository>();
config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => configs.TryGetValue(id, out var cfg) ? cfg : null);
var index = new ManagedTenantProIndex(admin.Object, config.Object, new MemoryCache(new MemoryCacheOptions()),
NullLogger<ManagedTenantProIndex>.Instance);
return (index, admin, config);
}
private static TenantConfiguration Tenant(string id, string tier, DateTime? trial = null) =>
new() { TenantId = id, PlanTier = tier, TrialExpiresUtc = trial };
[Fact]
public async Task OwnedGroup_OfPermanentProOwner_ConfersOnMembers()
{
var (index, _, _) = Build(
new[] { Owned(Msp, CustomerA, CustomerB) },
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerB));
Assert.Null(await index.GetConferringOwnerAsync(Msp)); // the owner never manages itself
Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
}
[Fact]
public async Task LegacyEnterpriseTier_CountsAsPermanentPro()
{
var (index, _, _) = Build(
new[] { Owned(Msp, CustomerA) },
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "enterprise") });
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task TrialOwner_ConfersNothing()
{
var (index, _, _) = Build(
new[] { Owned(TrialMsp, CustomerA) },
new Dictionary<string, TenantConfiguration>
{
[TrialMsp] = Tenant(TrialMsp, "community", trial: DateTime.UtcNow.AddDays(10)),
});
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task OwnerWithoutConfigRow_ConfersNothing()
{
var (index, _, _) = Build(new[] { Owned(Msp, CustomerA) }, new Dictionary<string, TenantConfiguration>());
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task OperatorGroup_NeverConfers_EvenWhenAssigneesAreProHomed()
{
var (index, _, _) = Build(
new[] { Operator(OperatorManaged) },
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
}
[Fact]
public async Task MutualManagement_ResolvesBothWays_WithoutRecursion()
{
// A manages B and B manages A, both permanent Pro: each owner's tier is one RAW read — the
// index never asks "is the owner itself managed", so there is no cycle to fall into.
var (index, _, config) = Build(
new[] { Owned(Msp, CustomerA), Owned(CustomerA, Msp) },
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro"), [CustomerA] = Tenant(CustomerA, "pro") });
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
Assert.Equal(CustomerA, await index.GetConferringOwnerAsync(Msp));
config.Verify(c => c.GetTenantConfigurationAsync(Msp), Times.Once);
config.Verify(c => c.GetTenantConfigurationAsync(CustomerA), Times.Once);
}
[Fact]
public async Task TwoProOwners_ResolveDeterministically_ByGroupId()
{
// GroupIds are "msp-{owner}", so ordinal order of the owner ids decides.
var first = "0aaaaaaa-0000-0000-0000-000000000000";
var second = "0bbbbbbb-0000-0000-0000-000000000000";
var (index, _, _) = Build(
new[] { Owned(second, CustomerA), Owned(first, CustomerA) },
new Dictionary<string, TenantConfiguration> { [first] = Tenant(first, "pro"), [second] = Tenant(second, "pro") });
Assert.Equal(first, await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task IsCachedAcrossLookups_AndInvalidateRebuilds()
{
var (index, admin, _) = Build(
new[] { Owned(Msp, CustomerA) },
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
await index.GetConferringOwnerAsync(CustomerA);
await index.GetConferringOwnerAsync(CustomerB);
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Once);
index.Invalidate();
await index.GetConferringOwnerAsync(CustomerA);
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Exactly(2));
}
[Fact]
public async Task StorageFailure_YieldsNobodyManaged_NeverThrows()
{
var admin = new Mock<IAdminRepository>();
admin.Setup(a => a.GetAllTenantGroupsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
var index = new ManagedTenantProIndex(admin.Object, Mock.Of<IConfigRepository>(),
new MemoryCache(new MemoryCacheOptions()), NullLogger<ManagedTenantProIndex>.Instance);
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task BlankTenantId_IsNull_WithoutTouchingStorage()
{
var (index, admin, _) = Build(Array.Empty<TenantGroup>(), new Dictionary<string, TenantConfiguration>());
Assert.Null(await index.GetConferringOwnerAsync(null));
Assert.Null(await index.GetConferringOwnerAsync(" "));
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Never);
}
[Fact]
public async Task None_ProjectsNobody()
{
Assert.Null(await ManagedTenantProIndex.None.GetConferringOwnerAsync(CustomerA));
ManagedTenantProIndex.None.Invalidate(); // no-op, must not throw
}
}
@@ -198,17 +198,17 @@ public class McpQuotaEnforcementMiddlewareTests
Assert.Equal("tenant", body.Level);
Assert.Equal(TenantB, body.TargetTenantId);
Assert.Contains("managed tenant 'customer.example'", body.Message);
Assert.Contains("tenant plan 'community'", body.Message);
Assert.Contains("Upgrading that tenant to Pro", body.Message);
Assert.DoesNotContain("your organization", body.Message);
Assert.Contains("managed tenant 'customer.example'", body.Error);
Assert.Contains("tenant plan 'community'", body.Error);
Assert.Contains("Upgrading that tenant to Pro", body.Error);
Assert.DoesNotContain("your organization", body.Error);
}
[Fact]
public void ManagedTarget_WithoutLabel_FallsBackToTheId()
{
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB));
Assert.Contains($"managed tenant '{TenantB}'", body.Message);
Assert.Contains($"managed tenant '{TenantB}'", body.Error);
}
[Fact]
@@ -216,8 +216,8 @@ public class McpQuotaEnforcementMiddlewareTests
{
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", TenantB, 3000, 3000), "customer.example");
Assert.Equal(TenantB, body.TargetTenantId);
Assert.DoesNotContain("Upgrading", body.Message);
Assert.Contains("shared by all its members and delegated admins", body.Message);
Assert.DoesNotContain("Upgrading", body.Error);
Assert.Contains("shared by all its members and delegated admins", body.Error);
}
[Fact]
@@ -225,8 +225,8 @@ public class McpQuotaEnforcementMiddlewareTests
{
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
Assert.Null(body.TargetTenantId);
Assert.Contains("of your organization", body.Message);
Assert.DoesNotContain("managed tenant", body.Message);
Assert.Contains("of your organization", body.Error);
Assert.DoesNotContain("managed tenant", body.Error);
}
[Fact]
@@ -235,13 +235,13 @@ public class McpQuotaEnforcementMiddlewareTests
// The quota is the upgrade lever: a member blocked by their own Community organization window is told
// that Community is sized for occasional use and that Pro lifts it — Pro members never see the hint.
var community = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
Assert.Contains("sized for occasional use", community.Message);
Assert.Contains("upgrading your organization to Pro", community.Message);
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Message);
Assert.Contains("sized for occasional use", community.Error);
Assert.Contains("upgrading your organization to Pro", community.Error);
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Error);
var pro = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", target: null, 3000, 3000));
Assert.DoesNotContain("upgrading", pro.Message);
Assert.DoesNotContain("occasional use", pro.Message);
Assert.DoesNotContain("upgrading", pro.Error);
Assert.DoesNotContain("occasional use", pro.Error);
}
[Fact]
@@ -249,9 +249,9 @@ public class McpQuotaEnforcementMiddlewareTests
{
// The delegated admin must learn that the CUSTOMER's plan (not their own Pro/MSP plan) governs the window.
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example");
Assert.Contains("on the Community plan", body.Message);
Assert.Contains("its own plan governs this window, not yours", body.Message);
Assert.Contains("Upgrading that tenant to Pro", body.Message);
Assert.Contains("on the Community plan", body.Error);
Assert.Contains("its own plan governs this window, not yours", body.Error);
Assert.Contains("Upgrading that tenant to Pro", body.Error);
}
[Theory]
@@ -266,9 +266,9 @@ public class McpQuotaEnforcementMiddlewareTests
dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now);
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision);
Assert.Equal("user", body.Level);
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Message);
Assert.Equal(expectHint, body.Message.Contains("Pro raises your daily and monthly windows"));
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Message);
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Error);
Assert.Equal(expectHint, body.Error.Contains("Pro raises your daily and monthly windows"));
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
}
[Fact]
@@ -277,8 +277,8 @@ public class McpQuotaEnforcementMiddlewareTests
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), exhaustedTenantCount: 3);
Assert.Null(body.TargetTenantId);
Assert.Equal("tenant", body.Level);
Assert.Contains("all 3 managed tenants", body.Message);
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Message);
Assert.Contains("all 3 managed tenants", body.Error);
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Error);
}
[Fact]
@@ -293,6 +293,6 @@ public class McpQuotaEnforcementMiddlewareTests
Assert.Null(body.TargetTenantId);
Assert.Equal(
"MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-09-03T00:00:00Z.",
body.Message);
body.Error);
}
}
@@ -1,3 +1,4 @@
using System;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
@@ -11,31 +12,34 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class MetricsFilterInjectionTests
{
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead) ----
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead; layout D-199) ----
[Fact]
public void BuildRuleStatsFilter_NormalInput_ProducesScopedFilter()
public void BuildRuleStatsFilter_NormalInput_IsOnePartitionRangePerScope()
{
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze");
Assert.Equal(
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
"PartitionKey ge 'tenant1_2024-01-01' and PartitionKey le 'tenant1_2024-02-01' and RuleType eq 'analyze'",
filter);
}
[Fact]
public void BuildRuleStatsFilter_OpenWindow_FallsBackToTheScopePrefixBounds()
{
Assert.Equal(
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~'",
TableStorageService.BuildRuleStatsFilter("tenant1", null, null, null));
}
[Fact]
public void BuildRuleStatsFilter_RuleTypeInjection_StaysInsideLiteral()
{
// The reported HIGH-severity payload.
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", null, null, "x' or RowKey ge '");
// The tenant scope must survive...
Assert.Contains("RowKey ge 'tenant1_'", filter);
Assert.Contains("RowKey lt 'tenant1_~'", filter);
// ...and the injected quote must be doubled, so the OR can't escape the string literal.
Assert.Equal(
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
filter);
}
@@ -45,14 +49,42 @@ public class MetricsFilterInjectionTests
{
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", payload, null, null);
Assert.Contains("PartitionKey ge ''' or RowKey ge '''", filter);
Assert.Contains("RowKey ge 'tenant1_'", filter);
Assert.Equal("PartitionKey ge 'tenant1_'' or RowKey ge ''' and PartitionKey lt 'tenant1_~'", filter);
}
[Fact]
public void BuildRuleStatsFilter_AllNull_ReturnsNull()
public void BuildRuleStatsFilter_ScopeInjection_CannotWidenToAnotherTenant()
{
Assert.Null(TableStorageService.BuildRuleStatsFilter(null, null, null, null));
var filter = TableStorageService.BuildRuleStatsFilter("t' or PartitionKey ge '", "2024-01-01", null, null);
Assert.Equal(
"PartitionKey ge 't'' or PartitionKey ge ''_2024-01-01' and PartitionKey lt 't'' or PartitionKey ge ''_~'",
filter);
}
[Fact]
public void BuildLegacyRuleStatsFilter_KeepsTheOldShapeWhileLegacyRowsCanExist()
{
var filter = TableStorageService.BuildLegacyRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
filter);
}
[Fact]
public void BuildLegacyRuleStatsFilter_IsGoneAfterTheRetentionHorizon()
{
Assert.Null(TableStorageService.BuildLegacyRuleStatsFilter("tenant1", null, null, null, new DateTime(2026, 12, 5, 0, 0, 0, DateTimeKind.Utc)));
}
[Fact]
public void BuildRuleStatsCleanupFilter_IsAScopedRangeBelowTheCutoff()
{
Assert.Equal(
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_2026-06-07'",
TableStorageService.BuildRuleStatsCleanupFilter("tenant1", "2026-06-07"));
}
// ---- TableUserUsageRepository.BuildUserUsageFilter (metrics/mcp-usage/user/{userId}) ----
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The notification-dispatch consumer resolves channel ids against the tenant's CURRENT
/// configuration — no URL or secret ever rides in the queue — and sends only to channels that
/// are still enabled. Plus the wire contract: the envelope round-trips through Newtonsoft with
/// the alert's facts, sections and severity intact.
/// </summary>
public class NotificationDispatchHandlerTests
{
private const string Tenant = "11111111-1111-1111-1111-111111111111";
[Fact]
public async Task Sends_to_enabled_channels_matching_the_envelope_ids_only()
{
var h = new Harness(new[]
{
Channel("teams", enabled: true),
Channel("slack", enabled: true),
Channel("old", enabled: false),
});
await h.Sut.HandleAsync(Envelope("teams", "old"), CancellationToken.None);
var sent = Assert.Single(h.Sent);
Assert.Equal(new[] { "teams" }, sent.Channels.Select(c => c.Id));
Assert.Equal("enrollment_succeeded", sent.Alert.EventType);
}
[Fact]
public async Task Unknown_channel_ids_are_skipped_without_sending()
{
var h = new Harness(new[] { Channel("teams", enabled: true) });
await h.Sut.HandleAsync(Envelope("deleted-meanwhile"), CancellationToken.None);
Assert.Empty(h.Sent);
}
[Fact]
public async Task Missing_tenant_configuration_drops_the_alert()
{
var h = new Harness(channels: null);
await h.Sut.HandleAsync(Envelope("teams"), CancellationToken.None);
Assert.Empty(h.Sent);
}
[Fact]
public void Envelope_round_trips_through_newtonsoft()
{
var envelope = Envelope("teams");
envelope.Alert.Facts.Add(new NotificationFact { Name = "Device", Value = "PC-0001" });
envelope.Alert.Severity = NotificationSeverity.Error;
var restored = JsonConvert.DeserializeObject<NotificationDispatchEnvelope>(
JsonConvert.SerializeObject(envelope))!;
Assert.Equal(Tenant, restored.TenantId);
Assert.Equal(new[] { "teams" }, restored.ChannelIds);
Assert.Equal("enrollment_succeeded", restored.Alert.EventType);
Assert.Equal(NotificationSeverity.Error, restored.Alert.Severity);
var fact = Assert.Single(restored.Alert.Facts);
Assert.Equal("PC-0001", fact.Value);
}
// ============================================================ Helpers ====
private static NotificationChannel Channel(string id, bool enabled) => new()
{
Id = id,
Name = id,
ProviderType = (int)WebhookProviderType.Slack,
Url = $"https://example.invalid/{id}",
Enabled = enabled,
NotifyOnSuccess = true,
};
private static NotificationDispatchEnvelope Envelope(params string[] channelIds) => new()
{
TenantId = Tenant,
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
ChannelIds = channelIds.ToList(),
Alert = new NotificationAlert
{
EventType = "enrollment_succeeded",
Title = "Enrollment Succeeded",
Summary = "PC-0001 enrolled",
Severity = NotificationSeverity.Info,
ThemeColor = "00AA00",
},
EnqueuedAt = DateTime.UtcNow,
};
private sealed class Harness
{
public List<(IReadOnlyList<NotificationChannel> Channels, NotificationAlert Alert)> Sent { get; } = new();
public NotificationDispatchHandler Sut { get; }
public Harness(IEnumerable<NotificationChannel>? channels)
{
var configService = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(),
NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
if (channels is null)
{
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
.ReturnsAsync((new TenantConfiguration { TenantId = Tenant }, false));
}
else
{
var config = new TenantConfiguration
{
TenantId = Tenant,
NotificationChannelsJson = NotificationChannel.SerializeList(channels),
};
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
.ReturnsAsync((config, true));
}
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!);
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>()))
.Callback<IEnumerable<NotificationChannel>, NotificationAlert>((c, a) => Sent.Add((c.ToList(), a)))
.Returns(Task.CompletedTask);
Sut = new NotificationDispatchHandler(
configService.Object, dispatcher.Object, NullLogger<NotificationDispatchHandler>.Instance);
}
}
}
@@ -44,6 +44,7 @@ public sealed class TenantCustomsArchiveContractTests
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
proConferral: null!,
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -670,7 +670,8 @@ public class TenantOffboardingHandlerDrainContractTests
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail,
TestProConferral.Inert(), logger)
{
}
@@ -486,7 +486,7 @@ public class TenantOffboardingHandlerOrderingTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -258,7 +258,7 @@ public class TenantOffboardingWorkerPoisonTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)

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