Compare commits

...

69 Commits

Author SHA1 Message Date
Oliver Kieselbach 9ba67162aa fix: session duration inflated by inherited tz-skewed IME log timestamps (#123)
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.

Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
  DO-collector poll path passes null and the adapter stamps the clock
  instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
  earliest-event timestamps more than 2h before the current anchor
  (MaxStartedAtBackwardShift), on all four mutation sites incl. the
  SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
  and honor the writer-declared offset instead of failing to match.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:54:41 +02:00
dependabot[bot] efc96258d6 chore(deps-dev): bump the web group (#122)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


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

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

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:19:47 +02:00
Oliver Kieselbach 9d8b0fc254 feat: tenant-config field schema - MCP writes get types right on the first attempt (#121)
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config
had to guess field names and JSON types from a config read. New reflection-
generated schema surface makes guessing unnecessary:

- Backend: GET config/fields-schema (GlobalAdminOnly) - built from the
  TenantConfiguration model + the patch service deny-lists at runtime, so it can
  NEVER drift from what the patch endpoint enforces. Per field: camelCase name,
  JSON type (string/boolean/integer/number), nullability (null clears), format
  hints (json / date-time), writability + deny reason, GA-only marker (phase 2),
  revert-protected marker. Literal route; FindPolicy literal-over-template
  precedence pinned by test (same mechanism as config/all).
- MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config
  description + GA instructions headline steer the model to call it before
  composing a patch.
- Tests: drift canary (every model property appears exactly once), deny-list
  consistency, type exemplars incl. the decimal SLA column class, policy +
  route-precedence pins, MCP catalog/registration/request-shape. Backend 3854
  green, vitest 465 green, tsc clean.

Deploy order: backend first, then MCP redeploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:33:04 +02:00
Oliver Kieselbach f3028041af feat(web): tenant list shows plan badge (Community/Pro) instead of Preview
The green "Preview" badge was the activation-gate whitelist marker from the
preview era. With public availability + auto-approve live it carried no
information. Rows now show the plan tier (legacy "enterprise" resolves to
Pro, everything else Community); the amber "Waitlist" badge still appears
for unapproved tenants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:50:56 +02:00
Oliver Kieselbach 3628c7bc79 fix(backend): revert rehydration - whole-valued double columns must not become Int32
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed
fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish
95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns
(decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble
throws on the type mismatch. The rehydrator now forces GetDouble for columns the
model declares as decimal/double/float, mirroring the existing DateTime handling.

The roundtrip canary test is hardened into a reflection filler that sets EVERY
writable property non-default (with a deliberately whole-numbered decimal), so no
column type can escape the fidelity check again; plus a focused pin on the exact
prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused
the write, the snapshot itself is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:45:11 +02:00
Oliver Kieselbach 395fdd49df Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first

New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.

Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.

Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).

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

* feat(backend): transactional tenant-config field patch + revert with verified writes

New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
  fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
  auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
  shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
  verify that EXACTLY the intended fields changed -> automatic ETag-conditional
  rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
  (latest by default); snapshots current state first (revert is revertible),
  preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.

Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.

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

* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes

First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
  reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
  required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
  current unless includeProtectedFields; revert is itself revertible

New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).

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

* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window

The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:30:05 +02:00
Oliver Kieselbach 5edb64f3c9 fix(web): confirm-dialog errors surface in-dialog + dark-mode sheet gaps
The GA offboard and app-homing confirm dialogs reported failures via the
admin page-level error banner, which sits behind the tenant editor modal
(z-50) and the dialogs themselves (z-[60]) - the user saw nothing. Both
dialogs now take an error prop and render the failure inline; the section
routes homing/offboard failures into dedicated states cleared on open.

Dark mode: class-by-class audit of the offboard UI against the global
override sheet. The dialogs were already covered; the real gaps were
missing family members in globals.css - border-{red,amber,green,purple}-100,
border-{blue,sky,yellow,rose,emerald}-300, and text-red-600/700 (mapped to
red-400, mirroring the cyan/rose 600-to-400 convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:56:17 +02:00
Oliver Kieselbach 46965af290 feat(backend): finalize + arm the offboarding farewell email
Replace the [DRAFT] placeholder with the final copy (thank-you, gentle
feedback pointer to GitHub/LinkedIn, welcome-back note, last-email notice)
and remove the OffboardFarewellEmailArmed scaffolding gate. Tests move from
the disarmed-by-default contract to the armed contract (gate ordering,
never-throw, template pins). GitHub links in both email templates now point
to the renamed repo okieselbach/AutopilotMonitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:20:27 +02:00
Oliver Kieselbach 1bfdb68ff6 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-02 18:02:08 +02:00
Oliver Kieselbach 5ee5114d5c updated modification datetimes page 2026-08-02 18:02:04 +02:00
Oliver Kieselbach a7bb7ebf15 feat: Pro requires a tenant contact address - gated at plan entry, never as a runtime lockout
Backend: POST config/{id}/trial now denies with 409 ContactEmailRequired
when no contact address is stored (extracted EvaluateTrialStart verdict,
order test-pinned: consumed/already-Pro win over the contact prompt). The
gate is live at the API even while the trial CTA stays behind its
"coming soon" flag; GA plan assignment stays unblocked by design. The
feature-flags payload gains contactEmailSet (bool only, never the
address - contract test updated).

Web: trial CTA shows a "set a contact address first" note instead of the
start button when the flag flips on; dashboard gets an amber non-blocking
banner for Pro tenants without a contact (admins only, fail-safe: only an
explicit contactEmailSet:false nags, so loading/error/older-backend states
stay quiet); GA tenant editor warns when assigning Pro to a tenant
without a contact address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:58:49 +02:00
Oliver Kieselbach a7a1cc43c1 fix(backend): welcome mail survives the auto-approve race - send-on-save + once-only marker
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.

Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:33:59 +02:00
Oliver Kieselbach 2a5ad65dd7 fix(web): explicit JSX spaces where Turbopack eats them - "onlyto" class of typos
Turbopack drops the ambient space between an inline element and adjacent
prose in certain multi-line JSX text layouts (Babel/SWC preserve it), so
the live portal rendered "onlyto reach you", "IsEnabledflag",
"...ps1script", "5 minutesfor GitHub's", "Unavailableand the cascade".
User-reported on the Contact section; the other five sites were found by
scanning the compiled bundle for space-less text nodes after elements.

Fix: explicit {" "} at each affected boundary - it compiles to its own
text-node and cannot be trimmed. Verified in a fresh build: all six sites
now emit ..."})," ","to reach... Sites whose gap comes from ml-*/mx-*
margins or flex gap were left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:31:04 +02:00
dependabot[bot] 960b3d8ed9 chore(deps): bump github/codeql-action from 4 to 4.37.3 (#119)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 16:52:33 +02:00
Oliver Kieselbach 86bb255b11 feat(web): GA offboard button in tenant management - with type-to-confirm and ban-semantics note
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.

Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:48:53 +02:00
Oliver Kieselbach d3a10dfb96 fix(backend): tenant activation is race-proof - conditional insert dedupes welcome mail and ops event
Double signup on two instances enqueued two auto-approve envelopes; both
workers could pass the approved-check (stale 30s negative cache) and both
ran the activation side effects - duplicate welcome mail plus duplicate
TenantAutoApproved ops event/Telegram ping. Same duplicate on queue
redelivery after a failed message delete.

The whitelist add is now a conditional INSERT (AddEntity, 409 = already
approved) so storage arbitrates concurrent activations: exactly one caller
sees "newly added" and runs mail/promote/ops event; losers drop quietly.
The GA approve endpoint stays idempotent (200 + alreadyApproved instead of
a duplicate mail on re-approve).

Also fixes a silent-failure path: the repo swallowed storage errors and
returned false, which ApproveAsync ignored - a failed whitelist write
still sent the welcome mail and reported success. Storage errors now
throw (queue path retries, HTTP path 500s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:32:13 +02:00
Oliver Kieselbach 80d0b30cba fix(backend): activation-gate review findings - OnboardedBy immutable via PUT, suspension gate reads fresh
Two findings from the re-run public-availability review:

- UpdateTenantConfigurationFunction preserved HomedAppClientId, LastAuth*
  and plan fields against round-trip clobber but not OnboardedBy, despite
  AuthFunction documenting it as write-once: any own-tenant admin PUT could
  overwrite or null it, corrupting audit provenance and letting a
  revoke -> re-approve cycle auto-promote an attacker-chosen UPN. Now
  preserved like the other system-owned fields.

- TenantAutoApproveHandler checked the suspension gate through the cached
  TryGetConfigurationAsync (5-min per-instance TTL): a tenant suspended
  right after signup could still be auto-approved from a stale view. Now
  reads via GetConfigurationFreshAsync; a storage error propagates so the
  message retries (flag re-checked per attempt) instead of silently
  downgrading the tenant to manual approval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:13:31 +02:00
Oliver Kieselbach aee723e497 fix(backend): app-homing flip reads config fresh - stale cache could rewind concurrent writes
FlipAsync read the tenant config through the cached GetConfigurationAsync
(5-min per-instance TTL, fail-open to CreateDefault) and saved the whole
entity back: a flip could silently rewind fields another writer changed
within the TTL, and a transient storage read error would have persisted a
default config over the real row. Now reads via GetConfigurationFreshAsync
(cache-bypassing, errors propagate) and aborts loudly when no config row
exists instead of materializing a default one.

Daily-review finding 2026-07-31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:01:25 +02:00
Oliver Kieselbach 427c923530 feat(web): mobile menu for landing navbar - sign in was unreachable on phones
Below md the landing navbar only showed the Get started CTA: Sign in was
hidden sm:block and the nav links (Product/Capabilities/Compare/Docs) had
no mobile counterpart at all. Returning users on a phone had no visible
way to log in.

- burger toggle (<md) opens a solid panel under the bar: nav links,
  GitHub, and a prominent Sign in button; closes on link click, backdrop
  tap, and Escape
- Get started stays always visible in the top bar; GitHub icon and Sign
  in text button move into the menu below md
- panel uses opaque --lp-surface: nested backdrop-blur inside the nav
  does not blur page content, a translucent panel let the hero bleed
  through
- 320px fit: px-4 below sm and the wordmark hides below 360px (mark only)

Verified via Playwright at 320/390/1280px: menu open/close paths, anchor
scroll to #features, desktop layout unchanged; tsc and ESLint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:12:34 +02:00
Oliver Kieselbach b46a8f1571 fix(rules): merge collision guard - stale tenant rule copies no longer crash rule notifications
Tenants seeded in the pre-global-partition era can still carry per-tenant
copies of built-in rules (IsBuiltIn=false debris, e.g. ANALYZE-ID-002 on one
prod tenant). They pass the custom-rule filter and put the same RuleId twice
into the merged list, blowing up ToDictionary(r => r.RuleId) in
AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync and silently
dropping rule channel notifications for that tenant.

Guard the merge in AnalyzeRuleService + GatherRuleService: a tenant row whose
RuleId collides with a merged global rule is skipped (global wins) with a
warning log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:29 +02:00
Oliver Kieselbach 6e294c010e fix(web): validation-gate toggles persist - disable was local state only
Prod report 2026-08-01 (tenant b54dc1af): corporate-identifier validation
"came back on" after every disable. Audit log shows the truth: not a single
True->False entry ever - the disable NEVER reached the server. The validation
section has no save bar, and only the consent-driven ENABLE paths persisted;
the toggle setters (disable via confirm dialog, and enabling the second gate
while the first already carries the consent) wrote plain React state that
silently reverted on the next config load.

New TenantConfigContext.saveValidationGate routes those setter calls through
the shared config PUT with explicit override values; local gate state re-syncs
from the server response (or snaps back on failure), and the section save
indicator covers the write.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:32:51 +02:00
Oliver Kieselbach dc54c9555c fix(web): correct Entra roles for admin consent - Application Administrator is not sufficient
The validation info box and the detect-access error still claimed Application
Administrator could grant the consent. Per MS Learn (grant-admin-consent),
Application/Cloud Application Administrator are excluded for Microsoft Graph
app roles (application permissions) - our consent includes the app-only
DeviceManagementServiceConfig.Read.All. Correct roles: Global Administrator
or Privileged Role Administrator, matching the funnel banner and docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:28 +02:00
Oliver Kieselbach 784fd99240 fix(web): consent verify rides AAD propagation; funnel banner actions + dark mode
- Post-consent verification now POLLS the access-check (up to 5 attempts,
  15s apart) instead of a single shot: right after a successful admin consent
  the app-only token often mints with an EMPTY roles claim for 30-90s
  (observed live: tenant 5ca2b350, GrantedRoleCount=0 at T+43s), which the
  single-shot probe misreported as "permission not granted" while the homing
  flip and consent were actually fine. The access-check invalidates and
  re-mints fresh per call, so the poll converges as soon as Microsoft has
  propagated the role; the consent spinner stays up meanwhile. Final "absent"
  message now names propagation as the likely cause instead of sounding fatal.
- Funnel banner gains always-visible actions ("Grant consent for the new app"
  + "Detect existing access" on the autopilot gate, idempotent when already
  enabled): tenants whose validations are already ON had no consent button to
  click, and the per-toggle detect link under Corporate Identifier would
  enable that validation as a side effect.
- Dark mode: funnel/flipped/legacy banners and the admins-only notice were
  light-palette-only and unreadable on dark.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:15:50 +02:00
Oliver Kieselbach 607d6615b0 fix(auth): app-homing flip survives stale full-model config writers
Prod incident 2026-07-31 18:03Z (tenant b54dc1af): the consent-driven homing
flip landed and 600ms later the frontend persisted the validation toggle via
the generic config PUT - round-tripping a config loaded BEFORE the flip. The
GA branch treated the stale null HomedAppClientId as an intentional revert to
legacy, silently undoing the flip (App Insights: AppHomingFlipped 18:03:00.44,
PUT revert warning 18:03:01.03).

- HomedAppClientId is now IMMUTABLE via the generic PUT for ALL callers, GA
  included: a full-model round-trip can never carry flip intent. The only
  writers are the consent auto-flip and POST config/{tenantId}/app-homing
  (which the GA tenant-management panel already uses).
- HandleAuthClientIdTrackingAsync (same failure class, cross-instance): the
  last-seen tracking write now mutates a cache-BYPASSING fresh read via new
  TenantConfigurationService.GetConfigurationFreshAsync instead of blind-saving
  the up-to-5-min-stale cached view - the login right after a flip is exactly
  the one that carries a changed audience and triggers this write.

3 regression tests (fresh-entity mutation preserves a concurrent flip,
fresh-already-current suppresses the write, steady state stays zero-I/O).
Backend xUnit 3767/3767.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:20:19 +02:00
Oliver Kieselbach 8d4f7ea0f9 fix(web): app-homing funnel banner - prominent card with permission hint + docs link
The plain info box was easy to overlook. Upgrade to a bordered gradient card
with icon and heading, state that admin consent needs a tenant-wide-consent
role (Global Administrator / Privileged Role Administrator), and link the
portal-setup docs article describing the required permissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:17:54 +02:00
Oliver Kieselbach 0e2d620ad4 feat(auth): self-service app-homing flip via consent flow + GA lever
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.

- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
  cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
  manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
  primary only, probe-gated; GA gets force + revert. Pure decision helper
  EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
  credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
  (AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
  GA tenant management (typed homedAppClientId hardens the PUT round-trip),
  funnel/success banners in tenant settings with switchAuthApp re-login,
  admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
  events survive; strategic events across the whole flow (app_homing_flipped,
  consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
  auth_app_homing_learned, app_homing_manual_flip*).

Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 19:52:23 +02:00
Oliver Kieselbach 69d61c6c26 feat(backend): dynamic poison-queue enumeration for health + ops monitoring
The static MonitoredPoisonQueues watch-list had already drifted twice
(tenant-auto-approve-poison, session-deletion-maintenance-poison missing).
Health check and maintenance watcher now enumerate every existing *-poison
queue in the storage account at runtime; an absent queue has never had a
poison-move and is healthy by definition, so enumeration covers everything
a list would. Enumeration failure surfaces as warning (never a silent
all-clear) on the health card and skips the maintenance tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:59:21 +02:00
Oliver Kieselbach cfb28d83b6 added png logo 2026-07-31 18:48:12 +02:00
Oliver Kieselbach f795fbcb4e feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).

Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
  CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
  operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
  one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
  am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
  PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
  the acting app per tenant (no-op until the legacy config pair is set); Graph
  client-credentials, admin-consent URLs and the surfaced client id follow the
  homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
  the middleware audience builders (was single-audience - would 401 legacy
  tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
  deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
  mcp-server.bicep default client id -> C4A8 app.

Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:12:25 +02:00
Oliver Kieselbach d9d22bc443 fix(web): drop Beta pills on Submit Logs and Report Session
Feature-maturity badges were a leftover of the preview era — with the product
publicly available, a lone "Beta" pill only signals uncertainty. Both features
are fully functional; the dashboard banner already carries the actively-developed
framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:38:43 +02:00
Oliver Kieselbach 6bd5728652 feat(platform): public availability — tenant activation replaces Private Preview, optional auto-approve
Backend: shared TenantApprovalService (whitelist add + auto-promote shape-guard +
welcome email), AdminConfiguration.AutoApproveNewTenants (default off, uncached
fail-closed accessor), tenant-auto-approve queue (1-min visibility delay, worker
drops on flag-off/approved/suspended/missing config, approves as "System
(auto-approve)" + TenantAutoApproved ops event), 30s negative whitelist cache,
gate 2 error PendingActivation with mode-neutral message, signup strings and
welcome email without preview wording.

Web: /preview → /activation (SWA 301, robots, AuthGate), AuthContext accepts both
error codes and clears pending state on successful auth/me, activation page polls
every 30s and auto-redirects into the portal, dashboard banner reframed to
"Actively developed" + feedback call, terms/privacy/get-started updated, feature
badges → Beta, admin-config web chain + auto-approve checkbox, OPS_EVENT_TYPES.

Deliberately unchanged: PreviewWhitelist/PreviewConfig tables, internal preview/*
API routes, preview_signup notification type, Microsoft DevPrep preview mentions.

Verified: solution build 0 errors, xUnit 3701/3701, tsc 0 errors, vitest 612/612,
ESLint ratchet holds (0/115), next build exports /activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:30:57 +02:00
Oliver Kieselbach d6affae8fc fix(web): plan card — bootstrap bullet wording (run already before MDM enrollment)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:40:56 +02:00
Oliver Kieselbach a70c5de667 feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request
- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy
  stored "enterprise" stays readable as Pro (no data migration); new
  entitlements BootstrapIncluded + UnrestrictedModeAvailable
- TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA
  flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle,
  read-time - trial expiry/downgrade re-arms guardrails fail-closed)
- All bootstrap endpoint gates + SecurityValidator use the effective check;
  /auth/me and feature-flags expose effective availability values
- PlanManagement accepts community|pro; AlreadyPro; Pro trial wording
- Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro
  labels/cards + new Pro feature bullets, effective section gating,
  terms/privacy renamed with Last-updated bump
- Tests: catalog matrix, effective-gate payload tests, helper tests incl.
  trial-expiry re-gate (backend 3692 green, web 612 green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:04:47 +02:00
Oliver Kieselbach e62bac9fea fix(web): hooks-refs batch 2.5 — ProtectedRoute wasAuthenticated ref to state; refs category at zero
wasAuthenticated was written AND read during render (the only such
case). Historical per-mount state belongs in useState, adjusted during
render via the guarded official pattern — behavior-identical: the value
is only consulted in !isAuthenticated renders, which can only follow a
render that already set it. Auth-gate control flow (auto re-login,
loading early-return, post-login return URL) untouched.

react-hooks/refs: 26 -> 0. Lint ratchet 115.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:29:27 +02:00
Oliver Kieselbach 1e94a7aa91 fix(web): hooks-refs batch 2.4 — useDashboardSessions mirror refs via useLatest
Nine render-phase mirror writes replaced: scope/filter/pagination
mirrors sync post-commit via useLatest; consuming callbacks list the
stable refs in their deps. Drops two write-only refs
(joinGlobalAdminsRef, loadingMoreRef) that nothing read. Imperative
refs (fetchGen/fetchLock/loadAllToken/submittedFilter) untouched.
Lint ratchet 119.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:24:32 +02:00
Oliver Kieselbach c330e5c91a fix(web): hooks-refs batch 2.3 — useDashboardStats mirror refs via useLatest
Seven scope mirror refs sync post-commit via useLatest instead of
render-phase writes; fetchStats lists the stable refs it reads in its
deps. Lint ratchet 128.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:19:22 +02:00
Oliver Kieselbach 6015548172 fix(web): hooks-refs batch 2.2 — useFleetHealth mirror refs via useLatest
Four scope mirror refs (routeGlobal, selectedTenantId, tenantId, days)
now sync post-commit via useLatest instead of render-phase writes.
fetchData lists the stable refs it reads in its deps. Lint ratchet
135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:16:10 +02:00
Oliver Kieselbach 3f661d097d fix(web): hooks-refs batch 2.1 — useLatest helper; useAuthenticatedFetch + presence off render-phase ref writes
Render-phase ref mirror writes tear under concurrent rendering
(react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive
effect instead; refs from custom hooks are not linter-known-stable, so
consumers list them in dep arrays (identity is stable — no re-creation).
Lint ratchet 141 -> 139.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:11:59 +02:00
Oliver Kieselbach 3392a23da6 feat(bootstrap): promote dev script v2.2 to stable — download.autopilotmonitor.com endpoint + OOBE-relax (#118)
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.

CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:37:17 +02:00
Oliver Kieselbach 5312216f6b fix(web): hooks-warnings batch 1 — static-components, purity, immutability to zero (#117)
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).

static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.

purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).

immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.

Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:21:29 +02:00
Oliver Kieselbach 72392f6f4c chore(web): lint findings to zero errors + CI lint gate (#116)
* feat(web): typed routes — broken internal links are now compile errors

typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

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

* chore(web): lint findings to zero errors + CI lint gate

Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).

Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
  of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
  (EnrollmentEvent.data et al -> Record<string, unknown> + per-event
  interfaces), SignalR passthroughs typed via the library's own
  signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
  require() is correct there
- one stale eslint-disable directive removed

Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.

Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:38 +02:00
Oliver Kieselbach d65157a092 feat(web): typed routes — broken internal links are now compile errors (#115)
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:26 +02:00
Oliver Kieselbach cf45911b69 Web upgrade: Next 16 + React 19 + MSAL v4 (#114)
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3

react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.

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

* fix(web): react 19 / msal v4 / next 16 code adaptations

- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
  storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
  dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
  (all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
  jumping instead of animating (Next 16 stopped suppressing CSS
  smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
  webpack-only analyzer, use --webpack for ANALYZE runs

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

* chore(web): untrack Next-managed next-env.d.ts, generate types in CI

Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.

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

* chore(web): migrate lint script to eslint CLI (next lint removed in 16)

Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:54:12 +02:00
Oliver Kieselbach 1593232898 fix(web): every nav click was a full-page reload — skipTrailingSlashRedirect broke RSC fetches
skipTrailingSlashRedirect (added for the next-dev /api proxy 308) also defines
__NEXT_MANUAL_TRAILING_SLASH, which disables the client router's trailing-slash
normalization. Under output:'export' the router then requested RSC payloads as
/<path>.txt while the build only emits /<path>/index.txt — every fetch 404ed and
Next silently degraded EVERY <Link> navigation to an MPA (full-page) load. Most
visible symptom: the sidebar remounted on each click and its expanded groups
collapsed.

- next.config.ts: dev-gate skipTrailingSlashRedirect (prod build normalizes
  links to /foo/ again; RSC fetches hit the emitted index.txt files)
- GlobalSidebar: normalize usePathname() (client nav now yields /foo/, hard
  loads /foo — exact matches against the slash-less nav hrefs need both), and
  re-run the auto-expand effect when visibleExpandableGroups arrives so a hard
  load (deep link, F5) still expands the active group once MSAL resolves

Verified: static export + local serve, marker survives Link nav (no document
request, /terms/index.txt 200); tsc clean; vitest 611/611.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:03:52 +02:00
Oliver Kieselbach e4c06a9564 Landing page v2 — scroll story, portal-first hero, Autopilot Monitor rebrand (#113)
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark

Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
  -> 09:43 MCP terminal demo (full session report via get_session_summary +
  get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
  (now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
  blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
  metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
  the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
  field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
  dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
  hostRouting) and staticwebapp.config.json unchanged

Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.

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

* fix(web): landing feedback round — linear story timeline, honest examples, dev stats

- Story is now one vertical timeline with a strict top-down reading order
  (time rail left, text left, visual right) instead of the zigzag grid;
  act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
  Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
  installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
  real figures or hides entirely on fetch failure (never fake numbers,
  never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
  "Pull ETL and event logs"

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

* feat(web): hero v3 — mission-control cockpit instead of single-session card

The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.

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

* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit

Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.

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

* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header

- Analysis-finding overlay removed: the dashboard shot stands alone (was
  colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
  rescripted from a real production analysis (anonymized): failed attempt
  with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
  transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
  type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
  duplicating the terminal

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

* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot

"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.

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

* feat(web): hero v6 — portal-first layout from approved design draft

- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
  Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
  sub left / CTA group right with trust line, portal shot nearly full
  width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
  detected" replaces success rate (underlines automated analysis),
  events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
  the band

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

* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing

- New public /get-started page: five-step schematic (sign in -> request
  access -> consent -> deploy bootstrapper -> watch live); "Get started"
  in navbar and hero now lead here, "Sign in" keeps the login handoff —
  resolving the two-identical-CTAs problem. Wired into hostRouting
  PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
  adaptive card has exactly one action ("Open session") — the invented
  "Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
  shadow only. Final CTA rebuilt as a calm two-column band per draft
  (no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
  under .dark .landing-v2, theme toggle removed from LandingNavbar
  (screenshots are light; portal keeps full dark support).

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

* feat(web): about/terms/privacy on the landing shell — no more sidebar flash

The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.

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

* fix(web): landing navbar anchors root-anchored so they work from subpages

#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.

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

* fix(web): calmer CTA green, about de-blued, public-page typography aligned

- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
  switch from bright #33b161 to the deep accent-ink green — matches the
  dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
  to the single green accent, audience cards/step bubbles/links/quick-link
  hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
  smaller page h1s and section headings

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

* fix(web): terms callouts on lp tokens — no more stray blue/amber banners

Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.

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

* fix(web): about tech stack — drop version numbers, fix stale SSR claim

Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.

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

* fix(web): terminal demo app renamed to Contoso CRM App

Neutral fictional app instead of a vendor-adjacent print driver name.

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

* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started

- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
  CTAs; sign-in stays in the navbar and on /get-started itself

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

* feat(web): portal brand aligned to landing, subtle Enterprise anchoring

- Portal navbar (both variants) now uses BrandMark + the landing wordmark
  style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
  completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
  subline + /get-started closing note ("same service, nothing to
  migrate") + "Plans" link in the footer Product column, all pointing to
  the docs plans page

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

* fix(web): final CTA right-aligned, get-started enterprise note "and more"

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

* chore(web): social images refreshed to current hero claim and tagline

"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.

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

* chore(web): social card wordmark single-color, matching the site

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

* feat(web): portal sidebar selection states green — brand-aligned like the docs

Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.

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

* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links

- About/terms/privacy no longer render half dark for dark-mode visitors:
  the light-only marketing scope now neutralizes the global .dark utility
  overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
  .landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
  just-ask, capabilities, comparison, how-it-works now share the same
  left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
  (old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
  and portal status texts intentionally unchanged)

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

* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned

- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
  accent notify box, neutral next-steps, ghost actions — "Preview"
  wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
  the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections

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

* fix(web): preview page — prominent approval-pending badge, honest status copy

- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
  me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
  blocked tenant never reaches a dashboard — signing in again lands back
  on this page while pending, and goes straight into the portal once
  approved (verified against AuthContext PrivatePreview handling)

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

* fix(web): about/terms/privacy cards flattened to the landing pinstripe style

All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.

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

* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon

- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
  green-600/700: admin backups, customs archive, vulnerability data,
  session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
  (light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched

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

* feat(web): portal links and focus rings on brand green

- All focus states unified: focus:ring/border blue-500 and indigo-500
  (207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
  variants (36) -> text-green-700 hover:text-green-800; icon hovers
  hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
  existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles

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

* fix(web): local dev sign-in repaired; progress portal brand accents green

Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
   /api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
   rejects trailing-slash routes, so /me silently failed.
   -> skipTrailingSlashRedirect: true (no effect on the static export;
   SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
   autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
   healthy (v1.5.1027).

Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.

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

* fix(web): loading screens and remaining primary buttons on brand green

- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
  UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
  ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
  replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
  hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
  retry/back buttons, error page, audit, diagnosis, session table

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

* fix(web): last blue interactive states green; monitoring headers wrap on mobile

- Time-range toggles (fleet health, software, usage, dashboard, platform
  usage), software lens tabs, MCP-usage segmented controls, SLA report
  button, gather/analyze rule save buttons, and the page-header icons on
  fleet health / geographic performance -> brand green. Zero remaining
  bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
  geographic performance, audit): title row gets flex-wrap + gap-y-3 and
  the control group (tenant scope + range buttons) wraps as a compact
  second line on narrow screens instead of pushing off the right edge;
  range buttons slightly compacted (px-3 py-1.5).

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

* fix(web): geo map dev crash — per-mount container key for Leaflet

"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.

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

* fix(web): form control accents (radios/checkboxes) green portal-wide

Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.

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

* fix(web): pin native form-control accent-color to brand green

Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.

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

* fix(web): progress portal icon inherits item color again

Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.

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

* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text

- Root cause of "Map container is already initialized": Next 15 app
  router runs its bundled React 19 regardless of the installed react@18,
  and react-leaflet 4.x is incompatible with React 19 StrictMode
  double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
  (@react-leaflet/core 3) and removed our now-counterproductive
  workarounds (MapCleanup manual remove, per-mount container key) — v5
  owns the map lifecycle including unmount teardown. Verified via a
  throwaway repro page: map renders, remount toggle survives, console
  clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
  #8896ad) — notification timestamps and other muted text were nearly
  invisible on raised dark surfaces (unread bg, hover rows).

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

* fix(web): subtler dark hover surfaces — hovered rows stay readable

.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.

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

* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides

The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.

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

* fix(web): notification row hover clearly distinguishable in dark mode

GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.

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

* feat(web): one SegmentedControl for all range/group toggles

New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.

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

* fix(web): MCP usage sections use the shared SegmentedControl too

The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.

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

* feat: clickable notification deep links in the bell dropdown

GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.

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

* chore(web): npm overrides pin react-leaflet peer deps to installed react

react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:21:18 +02:00
Oliver Kieselbach 9c50d98792 fix(web): sign-in landed back on www — HostRoutingGuard bounced before MSAL settled
Two defects of the same shape in the portal→www direction:
- Deterministic: a signed-in user opening portal root was always bounced to
  www (guard effect fires at hydration, before MSAL restores the session, so
  AuthGate never got to route "/" to /dashboard).
- Race: MSAL's module-level init strips the #code hash before hydration, so
  the auth-response exception saw nothing and the bounce destroyed the
  in-flight token redemption — the user ended up unauthenticated on www.

Portal→www now waits for settled, unauthenticated auth state; www→portal
stays auth-free. Decision extracted as pure decideHostBounce with the safety
rules pinned in hostRouting.guard.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:18:12 +02:00
Oliver Kieselbach 20b43e5349 feat(agent): gather debug log — phase transitions + per-match regex traces
- New "phase" stage: OnPhaseChanged writes a transition marker line
  (phase change: Old -> New) so rule-level lines read against the current
  phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
  and capture groups (<=100 chars, same selection as event data), capped at
  10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.

Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:07:52 +02:00
Oliver Kieselbach d0d1d28371 perf(backend): column projection for unmatched-software scan
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.

Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:22:07 +02:00
Oliver Kieselbach 5191751ed2 perf(web): lazy admin context + instant shell for software mapping
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.

/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.

Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:21:51 +02:00
Oliver Kieselbach 907118649b added playwright mcp 2026-07-29 16:00:17 +02:00
Oliver Kieselbach 26ecbf33d6 feat(web): instant-shell loading UX — table skeletons + route loading fallbacks
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.

- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
  loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
  table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
  auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
  triggered the MSAL redirect), usage-metrics, fleet-health, sla and
  admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
  pages) left untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:17:48 +02:00
Oliver Kieselbach adb103fc9e fix(web): always render the diagnostics opt-in in the report modal
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:11:04 +02:00
Oliver Kieselbach 26c8b80efa updated commands 2026-07-29 15:00:17 +02:00
Oliver Kieselbach 89c5c7da03 feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:13:33 +02:00
Oliver Kieselbach 48308df4ff feat: opt-in preservation of session diagnostics archive with session reports
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.

- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
  report), reuses the Hosted/CustomerSas read paths, re-verifies the
  hosted tenant prefix via ClassifyBlobName, honours the admin size cap
  and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
  (written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:53:12 +02:00
Oliver Kieselbach cefa57fde5 feat(web): track negotiated SignalR transport in telemetry
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:25:05 +02:00
Oliver Kieselbach 946d503be0 feat(mcp): emit RFC 9207 iss parameter in OAuth authorization response
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:41 +02:00
Oliver Kieselbach f744ad8406 fix code review findings 2026-07-29 10:30:47 +02:00
Oliver Kieselbach 5e9876bc5f fix: hard page reloads on family routes — RSC .txt payloads were wildcard-rewritten
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).

Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:48:08 +02:00
Oliver Kieselbach ffe88a47c6 fix: /sla dead-ended on www — it is a portal page, not a public one
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".

Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.

Web tsc + 603/603, backend build clean + SLA suite 38/38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:39:56 +02:00
Oliver Kieselbach 1f22b65d57 fix: /apps, /admin/backups, /admin/customs-archive list pages shadowed by detail rewrites
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:28:50 +02:00
Oliver Kieselbach 03afbf6ac6 fix: double sign-in on www→portal handover — silent SSO for app-triggered logins
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
  HostRoutingGuard's replace, signing the user in on the wrong origin.
  ProtectedRoute now stands down on the public host and lets the guard move
  the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
  including the automatic portal-side re-auth where Entra's session cookie
  would complete silently. login({auto:true}) (ProtectedRoute) drops the
  prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
  keep the picker.

Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:26:23 +02:00
Oliver Kieselbach 36172b8cc5 fix: www↔portal redirect loop — MSAL auth is per-origin, guards must not fight it
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.

Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
  response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
  no path — a bounce destroys the sign-in), plus a sessionStorage bounce
  counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
  return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
  window.location.href navigation (getPortalLoginUrl pattern) instead of
  router.push + guard cascade.

tsc clean, vitest 603/603, export build 87 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:16:32 +02:00
Oliver Kieselbach 8849a68b1c fix: og/twitter/apple-icon as static PNGs — extension-less exports served as octet-stream
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:06:01 +02:00
Oliver Kieselbach cc195990ff fix: SWA deploy — drop invalid is_static_export input, dedupe trailing-slash routes
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:52:28 +02:00
Oliver Kieselbach 18b28260a7 feat: portal static export — eliminate the SWA SSR runtime (cold-start root fix)
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.

- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
  /diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
  /admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
  in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
  consolidated. Suspense boundaries added everywhere useSearchParams drives
  page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
  the *NavSections registries, dynamicParams=false, client bodies extracted to
  SectionClient) — 37 pages; server redirect() index pages became
  ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
  webhook notifications cannot be edited): SWA wildcard rewrites serve the
  family page and the globally mounted LegacyPathRedirect rewrites to the
  canonical query URL, preserving query + #event hashes (mapping pinned by
  lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
  lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
  BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
  redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
  override, all security headers incl. literalized prod CSP (no unsafe-eval),
  Content-Type routes for the extension-less og images. Guarded by the new
  utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
  first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
  (query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.

Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:44:32 +02:00
Oliver Kieselbach 6cbd507b4f fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:01:31 +02:00
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00
Oliver Kieselbach 10308664dd small UI fix in session list entry for started 2026-07-28 17:47:33 +02:00
515 changed files with 18439 additions and 6846 deletions
+2 -2
View File
@@ -194,8 +194,8 @@ jobs:
# Dev variant = literal-string substitution of the two URL/manifest defaults.
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
$stableMan = '$VersionJsonName = "version.json"'
$devMan = '$VersionJsonName = "version-dev.json"'
+12
View File
@@ -69,9 +69,21 @@ jobs:
- run: npm ci
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
- name: Lint
run: npm run lint -- --max-warnings 115
- name: Tests
run: npm test
+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -68,6 +68,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.3
with:
category: /language:${{ matrix.language }}
+10 -1
View File
@@ -39,6 +39,10 @@ jobs:
uses: Azure/static-web-apps-deploy@v1
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
# and the default app for browsers without a stored selection (legacy|primary).
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
@@ -52,7 +56,12 @@ jobs:
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
output_location: "" # Built app content directory - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
close_pull_request_job:
+6
View File
@@ -53,6 +53,9 @@ __azurite_db*__.json
node_modules/
.next/
out/
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
# inside flips between .next/types and .next/dev/types) — must not be tracked.
next-env.d.ts
build/
dist/
!rules/dist/
@@ -148,3 +151,6 @@ plans/REFACTOR_AGENT_V2.md
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
+4
View File
@@ -3,6 +3,10 @@
"autopilot-monitor": {
"type": "http",
"url": "https://mcp.autopilotmonitor.com/mcp"
},
"playwright": {
"command": "cmd",
"args": ["/c", "npx", "@playwright/mcp@latest"]
}
}
}
+3 -3
View File
@@ -1,14 +1,14 @@
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
[![Status](https://img.shields.io/badge/status-Private%20Preview-orange)](https://www.autopilotmonitor.com)
[![Status](https://img.shields.io/badge/status-live-brightgreen)](https://www.autopilotmonitor.com)
[![License](https://img.shields.io/badge/license-MIT%20%2B%20AGPL--3.0-blue)](LICENSE)
[![Website](https://img.shields.io/badge/website-autopilotmonitor.com-2ea44f)](https://www.autopilotmonitor.com)
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
## Private Preview
## Availability
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
<p align="center">
<img src="docs/images/SessionList.png" width="45%" />
+24 -6
View File
@@ -58,14 +58,18 @@ when disabled — zero cost on the normal path). Line format:
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
```
Stages: `config` (registration summary per rule, header, zero-rules note), `trigger`
(timer scheduled, phase/event fired, dedup), `scope` (skips with reason incl. current
phase), `exec` (execution start), `collector` (null/empty results with likely cause),
`guard` (allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
(execution start), `collector` (null/empty results with likely cause), `guard`
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
position range, match count, parse failures, regex timeouts, mode; plus the
`format=text` hint when every line fails CMTrace parsing), `error` (full exception
**with stack trace** — the agent log keeps only the message).
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
match** — line number, matched text, capture groups — capped at 10 per file per run),
`error` (full exception **with stack trace** — the agent log keeps only the message;
an invalid logparser regex is reported here including the offending pattern string).
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
Every write failure is swallowed — tracing must never break gathering. The trace never
@@ -92,6 +96,7 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
```
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
... | - | phase | phase change: DeviceSetup -> AccountSetup
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
@@ -99,6 +104,19 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
```
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
```
and a broken regex names itself:
```
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
```
## Citations
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
+107
View File
@@ -0,0 +1,107 @@
---
type: Concept
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
tags:
- backend
- bootstrap
- security
- oobe
timestamp: 2026-07-28T00:00:00+02:00
---
# OOBE Bootstrap go-Code Flow
Bootstrap Sessions let a technician install the agent **before** Intune
enrollment: an admin creates a short-lived session in the portal (Settings →
Bootstrap Sessions), hands the technician a short URL, and the technician types
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
during OOBE. The URL is deliberately short because it is typed by hand on a
device that has no clipboard.
# Lifecycle
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
`PublicAnonymous`). Customers reach it through the Front Door custom domain
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
Validation runs in-process (`ValidateCodeAsync` + the tenant's
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
3. **Install** — the generated script downloads the agent ZIP from
`Constants.AgentDownloadBaseUrl`, extracts, and runs
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
from OOBE start, switches to certificate auth after Intune enrollment, and
self-destructs on completion.
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
URLs issued before the migration and is removed with the static-export PR —
see the transition rule in [URL Registry](../url-registry.md).
# Always-200 contract
Every response — success, invalid format, unknown/expired code, disabled
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
exception on the OOBE console, while a 200 error script surfaces the actual
message via `Write-Host 'ERROR: …'`. Consequences:
- Failures are invisible in status-code metrics; the compensating signal is the
`LogWarning` per rejection (code + client IP).
- Error messages are capped at 200 chars **before** quote-escaping and
single-quote-doubled — a hostile upstream message cannot break out of the
`Write-Host` literal.
A disabled tenant returns the **same generic message** as an unknown code — no
enumeration oracle for whether a code exists.
# Injection defense (two lines)
The script runs as SYSTEM during OOBE, so interpolation is treated as a
security boundary:
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
GUIDs, the download URL must be https on an allow-listed host
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
though values are produced in-process — defense-in-depth against tampered
table data.
2. **Single-quoted PS literals** — every interpolated value sits inside
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
backticks.
The template is a C# raw string with `__TOKEN__` placeholders substituted via
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
TypeScript template through a golden file, so the legacy route and the backend
serve identical scripts during the transition.
# Rate limiting behind Front Door
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
IPs are keyed, not AFD egress.
Front Door route caching is DISABLED for this route: the body inlines the
bearer token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
@@ -0,0 +1,75 @@
---
type: concept
title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-02
---
# Schema
## Activation
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
30 s negative cache).
Two activation routes, one shared implementation
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
Typical latency: ~1 minute after signup.
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
arbitrates concurrent activations; exactly one caller runs the side effects
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
## The welcome-mail race
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
same table) — the only reliable address: it is typed by the user on the activation-pending
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
**Arbitration — write-then-read on both sides plus a send-once marker:**
* The approval path writes the `approved` row, then reads the address.
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
activation for exactly this window).
* Since both do write-then-read, at least one sees both halves and calls
`TenantApprovalService.TrySendWelcomeEmailAsync`.
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
sends: at most one wins. The marker is consumed strictly **after** the address check —
consuming it without an address would permanently suppress the mail.
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
# Examples
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
notification-email save path"; user saves address at T+69 s → save path sees approved
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
* Address saved before manual approve (classic shape): approval path finds the address,
wins the marker, sends; a later re-save of the same address does not re-send.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+2
View File
@@ -33,9 +33,11 @@ https://docs.autopilotmonitor.com (separate repository).
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
# MCP Server
+11 -1
View File
@@ -1,6 +1,16 @@
# Log
## 2026-07-28
## 2026-08-02
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
+37 -1
View File
@@ -21,7 +21,7 @@ literal appears nowhere else.
| Component | Registry | Guard test |
|---|---|---|
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
Derived registries build on these instead of repeating hosts:
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
`build-agent.yml` — they exist precisely to keep already-deployed customer
bootstrap scripts working until the customer migration completes.
# Bootstrap go-URL (2026-07-28)
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
Function App (`GetBootstrapScriptFunction`). Registry entries:
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
independent oracle — before this, no test guarded the bootstrap URL shape at
all.
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
the `/agent/*` route above, sharpened: the script body inlines the bearer
token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
`/api/bootstrap/go/` prefix.
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
`middleware.ts` are deleted — verified safe because the BootstrapSessions
table held zero active sessions predating the URL cutover (all expired
2026-03). The backend is now the only script producer;
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
shipped template.
Portal detail routes are query-string based since the same PR
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
carries the production redirects/headers and is guarded by
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
unsafe-eval, route order).
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
+6
View File
@@ -94,6 +94,12 @@ Do not benchmark this with a synthetic burst. Firing requests in quick successio
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
and rounds 4–25 at p50 113 ms.
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
# Diagnosing this class of problem
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
+2 -2
View File
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
@description('Container image tag')
param imageTag string = 'latest'
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
@secure()
@description('Entra ID client secret for OAuth token exchange')
@@ -1,369 +0,0 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
Defaults to "version.json"; override only for parallel lab/dev manifests.
.PARAMETER MaxBootstrapWindowHours
Maximum device uptime (hours) within which the bootstrap is still considered
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
(registry deployment marker, WMI/filesystem user profile, last
logged-on user, 12 h bootstrap window).
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
pre-flight check to skip if agent already installed.
2026-02-05 Initial version.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
# Sleep/standby does not reset uptime, only real boot/restart does.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5: Is the agent already installed? (leftover from previous run)
if (Test-Path $AgentBinPath) {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
+138 -81
View File
@@ -5,18 +5,28 @@
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest
(legacy fallback: Content-MD5 header from the blob response)
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
URL to download the agent ZIP from. Defaults to the production download endpoint;
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
@@ -27,16 +37,35 @@
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
@@ -55,17 +84,20 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.0"
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -85,26 +117,19 @@ function Write-Log {
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
finally {
$md5.Dispose()
catch {
return $false
}
}
@@ -119,30 +144,65 @@ try {
exit 0
}
# Guard 2: No real user profile should exist yet (primary productive-device guard).
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$profileNames = @(
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
@@ -166,7 +226,7 @@ try {
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
@@ -181,38 +241,47 @@ try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
try {
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
catch {
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
@@ -227,29 +296,17 @@ try {
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
else {
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
@@ -1,213 +0,0 @@
# Autopilot Monitor Bootstrap Guard Test (DEV)
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
function Write-Step {
param(
[string]$Status,
[string]$Message
)
$prefix = switch ($Status) {
'PASS' { '[PASS]' }
'SKIP' { '[SKIP]' }
'WARN' { '[WARN]' }
'INFO' { '[INFO]' }
default { '[....]' }
}
Write-Host "$prefix $Message"
}
function Get-RegistryValueSafe {
param(
[string]$Path,
[string]$Name
)
try {
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
}
catch {
return $null
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
$wouldInstall = $true
$reasons = New-Object System.Collections.Generic.List[string]
Write-Host ''
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
Write-Host ''
# Guard 1: Agent was already deployed on this device (survives self-destruct)
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
if ($deployed) {
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
$wouldInstall = $false
$reasons.Add("Previously deployed marker exists: $deployed")
} else {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
try {
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
}
catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
$reasons.Add("Agent binary already present: $AgentExePath")
} else {
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
}
Write-Host ''
Write-Host '=== Result ==='
Write-Host ''
if ($wouldInstall) {
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
} else {
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
}
Write-Host ''
Write-Host '=== Summary ==='
if ($reasons.Count -eq 0) {
Write-Host 'No blocking reasons found.'
} else {
foreach ($reason in $reasons) {
Write-Host " - $reason"
}
}
Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
Write-Host "UptimeHours : $([string]$uptimeHours)"
Write-Host "AgentExePath : $AgentExePath"
Write-Host ''
+73 -36
View File
@@ -1,8 +1,11 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -58,63 +74,79 @@ if ($deployed) {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Guard 2: No real user profile should exist yet (primary productive-device guard)
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
$profileNames = @(
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object {
-not $_.Special -and
$_.LocalPath -like 'C:\Users\*'
} |
ForEach-Object {
Split-Path $_.LocalPath -Leaf
}
}
catch {
$wmiProfileQueryFailed = $true
}
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
# Filesystem view -- catches profiles WMI might miss
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) |
Where-Object {
$_ -and $_ -notmatch $excludePattern
} |
Select-Object -Unique
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
if ($profileNames) {
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
# This is "how old can the OOBE state be before I no longer trust it for initial install".
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
@@ -134,7 +166,7 @@ catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 4: Agent binary already present
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
@@ -168,6 +200,11 @@ Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
@@ -138,6 +138,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
Assert.Equal(2, changed["suppressedPolls"]);
}
[Fact]
public void PhaseChange_writes_transition_line()
{
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
_executor.WaitForStartupRules(30);
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
var trace = TraceContent();
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
// A repeated signal for the same phase must not produce a second transition line.
var marker = "phase change: DeviceSetup -> AccountSetup";
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
}
[Fact]
public void EmptyCollectorResult_is_traced_via_startup_execution()
{
@@ -108,6 +108,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
}
[Fact]
public void Match_traces_line_number_matched_text_and_groups()
{
var path = Path.Combine(_outsideUsers, "match.log");
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
new LogParserCollector().Execute(
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
Assert.Single(_events);
var trace = TraceContent();
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
Assert.Contains("groups: code=\"0x80070005\"", trace);
}
[Fact]
public void MatchTraces_are_capped_per_file()
{
var path = Path.Combine(_outsideUsers, "many.log");
var sb = new System.Text.StringBuilder();
for (var i = 1; i <= 15; i++)
sb.Append("hit number ").Append(i).Append('\n');
File.WriteAllText(path, sb.ToString());
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
Assert.Equal(15, _events.Count); // all matches still emit events
var trace = TraceContent();
Assert.Contains("many.log: line 10 matched", trace);
Assert.Contains("further matches not traced individually", trace);
Assert.DoesNotContain("many.log: line 11 matched", trace);
Assert.Contains("matched 15", trace); // summary still carries the full count
}
[Fact]
public void InvalidRegex_traces_the_pattern_itself()
{
var path = Path.Combine(_outsideUsers, "irrelevant.log");
File.WriteAllText(path, "content\n");
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("invalid regex pattern '([unclosed'", trace);
Assert.Contains("rule can never match", trace);
}
[Fact]
public void MissingFile_traces_no_files_matched()
{
@@ -92,6 +92,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
}
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
[Theory]
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
{
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
// lines with an explicit bias. Without bias handling the line either failed to
// match at all (old regex) or would be misread in agent-local time.
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.Equal(
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
entry.Timestamp);
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
}
// ── Oversized line → parses, no throw, no truncation of the message ───
[Fact]
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-do", 0);
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
adapter.TriggerDoTelemetryFromTest(app);
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
adapter.TriggerDoTelemetryFromTest(app);
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
}
[Fact]
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
{
// Session df1fcf47: the DO collector observed the completion live, but the event
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
// the past — which dragged Sessions.StartedAt back and inflated the duration.
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
// last matched line.
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-do-poll", 0);
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
Assert.Equal(ClockNow, info.OccurredAtUtc);
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
// Not a replay — live observation must never trip the replay suppression,
// even with a stale unrelated line on the tracker.
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
}
[Fact]
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
int priorDoCalls = 0;
int priorScriptCalls = 0;
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.OnImeAgentVersion("1.0.0.0");
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
Assert.Equal(1, priorVersionCalls);
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
{
using var f = new ImeLogTrackerAdapterFixture();
Action<string> priorVersion = _ => { };
Action<AppPackageState> priorDo = _ => { };
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
Action<ScriptExecutionState> priorScript = _ => { };
f.Tracker.OnImeAgentVersion = priorVersion;
f.Tracker.OnDoTelemetryReceived = priorDo;
@@ -22,9 +22,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
public static class CmTraceLogParser
{
// Pre-compiled regex for the CMTrace format
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
// UTC = local + bias) — without the optional bias group such lines would not match
// at all and their content would be invisible to every pattern.
private static readonly Regex CmTraceRegex = new Regex(
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Compiled | RegexOptions.Singleline
);
@@ -45,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var message = match.Groups["message"].Value;
var timeStr = match.Groups["time"].Value;
var biasStr = match.Groups["bias"].Value;
var dateStr = match.Groups["date"].Value;
var component = match.Groups["component"].Value;
var typeStr = match.Groups["type"].Value;
@@ -52,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
DateTime timestamp;
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
if (!TryParseTimestamp(dateStr, timeStr, biasStr, out timestamp))
{
timestamp = DateTime.UtcNow;
}
@@ -72,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return true;
}
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
private static bool TryParseTimestamp(string dateStr, string timeStr, string biasStr, out DateTime result)
{
result = DateTime.MinValue;
@@ -116,7 +120,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
"M-d-yyyy HH:mm:ss"
};
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
// Bias-suffixed time ("+480"): the writer declared its own UTC offset in minutes
// (GetTimeZoneInformation convention: UTC = written time + bias). Honor it instead
// of assuming the agent's local timezone — writer and agent can disagree (mixed
// components, mid-enrollment timezone change; session df1fcf47).
if (!string.IsNullOrEmpty(biasStr)
&& int.TryParse(biasStr, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var biasMinutes))
{
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out result))
{
result = DateTime.SpecifyKind(result.AddMinutes(biasMinutes), DateTimeKind.Utc);
return true;
}
return false;
}
// No bias: CMTrace timestamps are in LOCAL time. Parse as local, then convert to UTC.
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal, out result))
{
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
_stateDirty = true;
}
catch (Exception ex)
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
public Action<AppPackageState> OnDoTelemetryReceived { 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
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
/// <summary>
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
$"invalid regex pattern — rule can never match: {ex.Message}");
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
return null;
}
@@ -133,6 +133,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
return null;
}
// Per-match trace lines are capped per file per run — the per-file summary line
// still carries the full match count, this only bounds the per-match detail.
private const int MaxTracedMatchesPerFile = 10;
/// <summary>
/// Writes one debug-trace line per regex match: the line number, the matched text,
/// and every capture group that will land in the emitted event (same "0" exclusion
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
/// </summary>
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
int lineNumber, Match match, Regex pattern, int matchNumber)
{
if (matchNumber > MaxTracedMatchesPerFile)
{
if (matchNumber == MaxTracedMatchesPerFile + 1)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
return;
}
var sb = new StringBuilder();
sb.Append(fileName).Append(": line ").Append(lineNumber)
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
var first = true;
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (!group.Success) continue;
sb.Append(first ? " — groups: " : ", ");
first = false;
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
}
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
}
private static bool HasWildcard(string fileNamePart)
=> !string.IsNullOrEmpty(fileNamePart) &&
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
@@ -258,6 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
else
{
@@ -317,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
}
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public sealed class GatherRuleDebugLog
{
public const string StageConfig = "config";
public const string StagePhase = "phase";
public const string StageTrigger = "trigger";
public const string StageScope = "scope";
public const string StageExec = "exec";
@@ -248,10 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
// phase that failed — that snapshot is the whole point at a failure boundary.
List<GatherRule> exitRules = null;
EnrollmentPhase previousPhase;
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
var previousPhase = _currentPhase;
previousPhase = _currentPhase;
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
{
var previousPhaseName = previousPhase.ToString();
@@ -291,6 +292,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
// Transition marker first, so every rule-level line below reads against it.
// Written outside _scopeLock like all trace lines.
if (previousPhase != newPhase)
DebugLog(null, GatherRuleDebugLog.StagePhase,
$"phase change: {previousPhase} -> {phaseName}" +
(previousPhase == EnrollmentPhase.Unknown
? " (first phase signal — scoped rules now evaluate against this phase)"
: ""));
if (exitTraces != null)
{
foreach (var trace in exitTraces)
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
getPackageStates: () => imeHost.PackageStates,
onDoTelemetryReceived: pkg =>
{
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
// null source timestamp: poll-driven completion, no log line backs it —
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
},
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
private readonly Action<string>? _prevOnPatternMatched;
private readonly Action<string>? _prevOnImeAgentVersion;
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
private readonly Action<string> _ourOnPatternMatched;
private readonly Action<string> _ourOnImeAgentVersion;
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitImeAgentVersion(version);
}
private void OnDoTelemetryReceived(AppPackageState app)
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
{
_prevOnDoTelemetryReceived?.Invoke(app);
EmitDoTelemetry(app);
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
EmitDoTelemetry(app, sourceTimestampUtc);
}
private void OnScriptCompleted(ScriptExecutionState script)
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitAppState(app, oldState, newState);
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
EmitDoTelemetry(app, sourceTimestampUtc);
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
}
private void EmitDoTelemetry(AppPackageState app)
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
{
if (app == null || string.IsNullOrEmpty(app.Id)) return;
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
// Two trigger paths with different time grounding (session df1fcf47: a single
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
// session StartedAt back 7h and inflated the reported duration):
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
// event time — resolve/clamp it and apply historic-replay suppression.
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
DateTime now;
bool derivedFromClock;
DateTime? rawSourceTs;
if (sourceTimestampUtc.HasValue)
{
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
// Historic replay — a week-old DO summary is not this enrollment's download.
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
return;
// Historic replay — a week-old DO summary is not this enrollment's download.
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
return;
}
else
{
now = _clock.UtcNow;
derivedFromClock = true;
rawSourceTs = null;
}
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
@@ -0,0 +1,34 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value + round-trip tests for <see cref="AdminConfiguration.AutoApproveNewTenants"/>.
/// The flag gates the tenant auto-approve queue worker — defaulting to false keeps new tenant
/// signups on manual Global Admin approval until the operator deliberately opts in.
/// </summary>
public class AdminConfigurationAutoApproveNewTenantsTests
{
[Fact]
public void AutoApproveNewTenants_defaults_to_false_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.False(cfg.AutoApproveNewTenants);
}
[Fact]
public void AutoApproveNewTenants_defaults_to_false_on_CreateDefault()
{
// CreateDefault() is called when no admin-config row exists yet — if it somehow
// enabled auto-approve, tenants would activate without any operator opt-in.
var cfg = AdminConfiguration.CreateDefault();
Assert.False(cfg.AutoApproveNewTenants);
}
[Fact]
public void AutoApproveNewTenants_true_persists_on_the_config()
{
var cfg = new AdminConfiguration { AutoApproveNewTenants = true };
Assert.True(cfg.AutoApproveNewTenants);
}
}
@@ -51,5 +51,7 @@ public class AdminConfigurationRateLimitSyncTests
// ...but NO tenant configuration is enumerated or mutated (sync removed).
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
}
@@ -0,0 +1,64 @@
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>
/// Default-value + fail-closed tests for <see cref="AdminConfiguration.SelfServiceAppHomingEnabled"/>.
/// The flag is the kill switch of the dual app-reg self-service homing flip — defaulting to false
/// keeps the consent funnel and auto-flip off until the operator deliberately opts in, and a
/// storage error must read as "disabled" (kill switch wins over a blip).
/// </summary>
public class AdminConfigurationSelfServiceAppHomingTests
{
[Fact]
public void SelfServiceAppHoming_defaults_to_false_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.False(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public void SelfServiceAppHoming_defaults_to_false_on_CreateDefault()
{
var cfg = AdminConfiguration.CreateDefault();
Assert.False(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public void SelfServiceAppHoming_true_persists_on_the_config()
{
var cfg = new AdminConfiguration { SelfServiceAppHomingEnabled = true };
Assert.True(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public async Task Reader_returns_true_when_flag_enabled()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { SelfServiceAppHomingEnabled = true });
var svc = new AdminConfigurationService(
repo.Object, NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
Assert.True(await svc.IsSelfServiceAppHomingEnabledAsync());
}
[Fact]
public async Task Reader_fails_closed_on_repository_error()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = new AdminConfigurationService(
repo.Object, NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
Assert.False(await svc.IsSelfServiceAppHomingEnabledAsync());
}
}
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
result, "DESKTOP-CONTOSO1", "SN-1234",
"https://portal.autopilotmonitor.com/sessions/abc");
"https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Equal("analyze_rule_fired", alert.EventType);
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
}
@@ -0,0 +1,135 @@
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Authorization/validation matrix for <see cref="AppHomingService.EvaluateManualFlip"/> — the
/// pure decision behind <c>POST config/{tenantId}/app-homing</c>. Contract: tenant admins may
/// only flip TO primary, without force, with the self-service flag on, and only after a
/// successful consent probe; Global Admins may flip both directions and force past the probe.
/// </summary>
public class AppHomingDecisionTests
{
private static readonly AppHomingProbeResult ProbeOk = new(Succeeded: true, IsTransient: false);
private static readonly AppHomingProbeResult ProbeFail = new(Succeeded: false, IsTransient: false);
private static readonly AppHomingProbeResult ProbeTransient = new(Succeeded: false, IsTransient: true);
private static AppHomingDecision Evaluate(
bool isGlobalAdmin = false,
bool selfServiceEnabled = true,
bool legacyConfigured = true,
bool currentlyPrimary = false,
bool targetPrimary = true,
bool force = false,
AppHomingProbeResult? probe = null)
=> AppHomingService.EvaluateManualFlip(
isGlobalAdmin, selfServiceEnabled, legacyConfigured, currentlyPrimary, targetPrimary, force, probe);
[Fact]
public void ParallelWindowInactive_denies_for_everyone()
{
var decision = Evaluate(isGlobalAdmin: true, legacyConfigured: false, force: true);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("parallel-window-inactive", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Theory]
[InlineData(false, false)] // legacy-homed, target legacy
[InlineData(true, true)] // primary-homed, target primary
public void AlreadyAtTarget_is_a_noop(bool currentlyPrimary, bool targetPrimary)
{
var decision = Evaluate(currentlyPrimary: currentlyPrimary, targetPrimary: targetPrimary);
Assert.Equal(AppHomingDecisionKind.AllowNoOp, decision.Kind);
}
[Fact]
public void NonGa_revert_to_legacy_is_denied()
{
var decision = Evaluate(currentlyPrimary: true, targetPrimary: false);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("revert-is-ga-only", decision.ReasonCode);
Assert.Equal(403, decision.StatusCode);
}
[Fact]
public void NonGa_force_is_denied()
{
var decision = Evaluate(force: true);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("force-is-ga-only", decision.ReasonCode);
Assert.Equal(403, decision.StatusCode);
}
[Fact]
public void NonGa_with_flag_off_is_denied()
{
// The kill switch: turning the admin-config flag off must stop tenant-admin flips
// immediately, while the GA path below stays open (operator lever survives).
var decision = Evaluate(selfServiceEnabled: false);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("self-service-disabled", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Fact]
public void Ga_with_flag_off_still_requires_only_the_probe()
{
var decision = Evaluate(isGlobalAdmin: true, selfServiceEnabled: false);
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void FlipToPrimary_without_probe_requires_probe()
{
var decision = Evaluate();
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void FlipToPrimary_with_successful_probe_is_allowed()
{
var decision = Evaluate(probe: ProbeOk);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
[Fact]
public void FlipToPrimary_with_failed_probe_is_denied()
{
var decision = Evaluate(probe: ProbeFail);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("probe-failed", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Fact]
public void FlipToPrimary_with_transient_probe_is_denied_as_retryable()
{
var decision = Evaluate(probe: ProbeTransient);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("probe-transient", decision.ReasonCode);
Assert.Equal(503, decision.StatusCode);
}
[Fact]
public void Ga_flip_without_force_also_requires_probe()
{
var decision = Evaluate(isGlobalAdmin: true);
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void Ga_force_skips_the_probe()
{
var decision = Evaluate(isGlobalAdmin: true, force: true);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
[Fact]
public void Ga_revert_to_legacy_needs_no_probe()
{
// The legacy app is consented by construction — probing it would be meaningless.
var decision = Evaluate(isGlobalAdmin: true, currentlyPrimary: true, targetPrimary: false);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
}
@@ -0,0 +1,289 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
/// </summary>
public class AppHomingServiceTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
private const string Actor = "admin@contoso.com";
private readonly Mock<AdminConfigurationService> _adminConfigMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<GraphTokenService> _graphTokenMock;
private readonly Mock<IGraphFeatureDetector> _detectorMock;
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
private readonly List<OpsEventEntry> _savedOpsEvents = new();
private readonly EntraAppRegistry _registry;
private readonly AppHomingService _sut;
public AppHomingServiceTests() : this(legacyConfigured: true) { }
private AppHomingServiceTests(bool legacyConfigured)
{
var configValues = new Dictionary<string, string?>
{
["EntraId:ClientId"] = PrimaryId,
["EntraId:ClientSecret"] = "primary-secret",
};
if (legacyConfigured)
{
configValues["EntraId:LegacyClientId"] = LegacyId;
configValues["EntraId:LegacyClientSecret"] = "legacy-secret";
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
_registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
_adminConfigMock = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
_tenantConfigMock = new Mock<TenantConfigurationService>(
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
_graphTokenMock = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance,
Mock.Of<System.Net.Http.IHttpClientFactory>(),
cache,
configuration,
_registry,
_tenantConfigMock.Object)
{ CallBase = false };
_detectorMock = new Mock<IGraphFeatureDetector>();
_maintenanceMock = new Mock<IMaintenanceRepository>();
_maintenanceMock
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_sut = new AppHomingService(
NullLogger<AppHomingService>.Instance,
_adminConfigMock.Object,
_tenantConfigMock.Object,
_registry,
_graphTokenMock.Object,
_detectorMock.Object,
_maintenanceMock.Object,
opsService,
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
var config = LegacyHomedConfig();
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
SetupProbe(GraphTokenResult.Success("tok"));
}
private static TenantConfiguration LegacyHomedConfig()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.HomedAppClientId = null;
return config;
}
private void SetupProbe(GraphTokenResult result) =>
_graphTokenMock
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
[Fact]
public async Task Funnel_is_eligible_for_legacy_homed_tenant_with_flag_on()
{
Assert.True(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_is_not_eligible_when_legacy_pair_unconfigured()
{
var sut = new AppHomingServiceTests(legacyConfigured: false)._sut;
Assert.False(await sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_is_not_eligible_for_primary_homed_tenant()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
Assert.False(await _sut.IsFunnelEligibleAsync(config));
}
[Fact]
public async Task Funnel_is_not_eligible_when_flag_off()
{
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
Assert.False(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_does_not_block_on_entra_app_roles()
{
// Deliberate: app-role tenants may flip (operator informs them personally); the
// reminder trail is asserted in the flip test below.
var config = LegacyHomedConfig();
config.EntraAppRolesEnabled = true;
Assert.True(await _sut.IsFunnelEligibleAsync(config));
}
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
[Fact]
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
{
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
d != null && d.ContainsKey("HomedAppClientId"))), Times.Once);
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
{
SetupProbe(GraphTokenResult.PermanentFailure());
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task AutoFlip_does_not_flip_on_transient_probe()
{
SetupProbe(GraphTokenResult.TransientFailure());
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task AutoFlip_is_not_eligible_for_primary_homed_tenant_and_never_probes()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
var outcome = await _sut.TryAutoFlipToPrimaryAsync(config, Actor);
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(It.IsAny<string>(),
It.IsAny<EntraAppCredentials>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AutoFlip_is_not_eligible_when_flag_off()
{
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// ── FlipAsync ───────────────────────────────────────────────────────────
[Fact]
public async Task Flip_of_entra_app_roles_tenant_emits_reminder_ops_event()
{
var config = LegacyHomedConfig();
config.EntraAppRolesEnabled = true;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task Flip_noops_when_already_at_target()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
{
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
// the fresh read returning null is a hard stop.
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync((TenantConfiguration?)null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_back_to_legacy_persists_null_homing()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
}
}
@@ -24,6 +24,7 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
private readonly AuthFunction _sut;
public AuthFunctionSideEffectTests()
@@ -44,7 +45,7 @@ public class AuthFunctionSideEffectTests
var delegatedAdminMock = new Mock<DelegatedAdminService>(
adminRepo,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, Mock.Of<ILogger<DelegatedAdminService>>())
{ CallBase = false };
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
@@ -76,6 +77,7 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
_metricsRepoMock = new Mock<IMetricsRepository>();
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
_sut = new AuthFunction(
Mock.Of<ILogger<AuthFunction>>(),
@@ -87,11 +89,15 @@ public class AuthFunctionSideEffectTests
previewMock.Object,
_telegramMock.Object,
_globalNotificationMock.Object,
mcpUserMock.Object);
mcpUserMock.Object,
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
// Default: all fire-and-forget calls succeed
_tenantConfigMock
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()))
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -126,12 +132,89 @@ public class AuthFunctionSideEffectTests
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
Assert.Equal(Upn, config.OnboardedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Preview Signup",
"preview_signup", "New Tenant Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
null), Times.Once);
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
// Signup enqueues the delayed auto-approve unconditionally — the worker is the
// decision point (flag check at processing time).
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.Is<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
e => e.TenantId == TenantId && e.SignupUpn == Upn),
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------------------
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
// read, never the (possibly stale) cached view. Regression for the prod
// incident 2026-07-31: the login right after an app-homing flip carries the
// changed audience; blind-saving the stale cached entity reverted the flip.
// -------------------------------------------------------------------------
private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
[Fact]
public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip()
{
// Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = LegacyAppId;
cached.HomedAppClientId = null;
var fresh = DefaultConfig();
fresh.DomainName = "contoso.com";
fresh.LastAuthClientId = LegacyAppId;
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
Assert.NotNull(fresh.LastAuthClientIdSince);
}
[Fact]
public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite()
{
// Another instance already recorded the new client id — the fresh re-check must
// suppress the redundant write even though the cached view still looks outdated.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = LegacyAppId;
var fresh = DefaultConfig();
fresh.DomainName = "contoso.com";
fresh.LastAuthClientId = PrimaryAppId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead()
{
// Steady state (same app as last time) must stay a zero-I/O no-op.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = PrimaryAppId;
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -156,7 +239,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@@ -168,7 +251,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -179,7 +262,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -196,7 +279,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
// SaveConfig should still have been called before the fire-and-forget
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
// -------------------------------------------------------------------------
@@ -217,7 +300,7 @@ public class AuthFunctionSideEffectTests
Assert.Null(config.DisabledReason);
Assert.Null(config.DisabledUntil);
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
@@ -230,7 +313,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -241,7 +324,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -254,7 +337,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// -------------------------------------------------------------------------
@@ -223,11 +223,11 @@ public class AuthFunctionTests
}
// -------------------------------------------------------------------------
// Gate 2: Preview Gate
// Gate 2: Activation Gate
// -------------------------------------------------------------------------
[Fact]
public void PreviewGate_NotApproved_Returns403PrivatePreview()
public void PreviewGate_NotApproved_Returns403PendingActivation()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
@@ -238,7 +238,7 @@ public class AuthFunctionTests
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
var body = ToDynamic(result.Body);
Assert.Equal("PrivatePreview", (string)body.error);
Assert.Equal("PendingActivation", (string)body.error);
}
[Fact]
@@ -203,6 +203,38 @@ public class AuthenticationMiddlewareTokenValidationTests
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
}
// ── Dual app-reg window (C4A8 move): primary=new app, legacy=old app via LegacyClientId ──
// The middleware folds EntraId:LegacyClientId into the additional-id set via
// CombineAdditionalClientIdSources, so both apps' tokens validate in bare AND api:// form.
private const string NewAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
private const string OldAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
[Theory]
[InlineData(NewAppId)]
[InlineData("api://" + NewAppId)]
[InlineData(OldAppId)]
[InlineData("api://" + OldAppId)]
public void DualAppWindow_accepts_both_apps_tokens_in_both_audience_forms(string audience)
{
var clientIds = AuthenticationMiddleware.ResolveConfiguredClientIds(
NewAppId,
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, null),
out _);
Assert.NotNull(ValidateWith(Mint(RsaCreds, audience: audience), clientIds));
}
[Fact]
public void CombineAdditionalClientIdSources_joins_and_skips_empty_sources()
{
Assert.Equal($"{OldAppId},{SecondaryClientId}",
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, SecondaryClientId));
Assert.Equal(OldAppId,
AuthenticationMiddleware.CombineAdditionalClientIdSources($" {OldAppId} ", null, ""));
Assert.Null(AuthenticationMiddleware.CombineAdditionalClientIdSources(null, "", " "));
}
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
[Fact]
@@ -30,6 +30,9 @@
<None Update="device-cert-sample.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="TestData\oobe-bootstrap-golden.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,120 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the injection-defense invariants of <see cref="BootstrapScriptValueValidator"/>,
/// ported from the portal's utils/bootstrapValidation.ts (and its vitest suite). The
/// validated values are interpolated into a PowerShell script that runs as SYSTEM
/// during OOBE — any relaxation here widens the injection surface.
/// </summary>
public class BootstrapScriptValueValidatorTests
{
private const string GoodTenant = "11111111-1111-1111-1111-111111111111";
private const string GoodToken = "22222222-2222-2222-2222-222222222222";
private const string GoodUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static DateTime FutureExpiry => DateTime.UtcNow.AddHours(24);
private static bool Validate(
string tenantId = GoodTenant,
string token = GoodToken,
string url = GoodUrl,
DateTime? expiresAt = null,
BootstrapScriptValueValidator.ValidationFailure? expectedFailure = null)
{
var ok = BootstrapScriptValueValidator.TryValidate(
tenantId, token, url, expiresAt ?? FutureExpiry, out _, out var failure);
if (!ok && expectedFailure.HasValue)
Assert.Equal(expectedFailure.Value, failure);
return ok;
}
[Fact]
public void Accepts_valid_values()
{
Assert.True(Validate());
}
// ── tenantId / token: exact canonical GUIDs only ──
[Theory]
[InlineData("")]
[InlineData("not-a-guid")]
[InlineData("11111111111111111111111111111111")] // "N" format — no hyphens
[InlineData("{11111111-1111-1111-1111-111111111111}")] // "B" format
[InlineData(" 11111111-1111-1111-1111-111111111111")] // leading space (TryParseExact would trim)
[InlineData("11111111-1111-1111-1111-11111111111$")]
public void Rejects_non_canonical_tenant_ids(string tenantId)
{
Assert.False(Validate(tenantId: tenantId,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.TenantId));
}
[Fact]
public void Rejects_non_guid_token()
{
Assert.False(Validate(token: "'; $(calc); '",
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.Token));
}
// ── agentDownloadUrl: https + host allow-list + strict path ──
[Theory]
[InlineData("https://evil.example.com/x.zip'; $(calc); '")] // hostile payload from the vitest suite
[InlineData("https://evil.example.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("http://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // not https
[InlineData("https://download.autopilotmonitor.com:8443/agent/AutopilotMonitor-Agent.zip")] // explicit port
[InlineData("https://user:pw@download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // userinfo
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip?x=1")] // query
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip#f")] // fragment
[InlineData("https://download.autopilotmonitor.com/other/AutopilotMonitor-Agent.zip")] // wrong prefix
[InlineData("https://download.autopilotmonitor.com/agent/.hidden.zip")] // leading dot
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.exe")] // not .zip
[InlineData("")]
public void Rejects_disallowed_download_urls(string url)
{
Assert.False(Validate(url: url,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
}
[Theory]
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip")] // legacy blob host
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-v2.zip")]
public void Accepts_allowlisted_download_urls(string url)
{
Assert.True(Validate(url: url));
}
[Fact]
public void Rejects_filename_longer_than_80_chars()
{
// First char + 80 more before ".zip" exceeds the {0,79} tail bound.
var tooLong = "https://download.autopilotmonitor.com/agent/a" + new string('b', 80) + ".zip";
Assert.False(Validate(url: tooLong,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
var atLimit = "https://download.autopilotmonitor.com/agent/a" + new string('b', 75) + ".zip";
Assert.True(Validate(url: atLimit));
}
// ── expiresAt: strictly future, at most 14 days out ──
[Fact]
public void Rejects_past_expiry()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddMinutes(-1),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Rejects_expiry_beyond_14_days()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddDays(15),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Accepts_expiry_within_the_window()
{
Assert.True(Validate(expiresAt: DateTime.UtcNow.AddDays(7)));
}
}
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The universal pre-write snapshot hook in <see cref="TableConfigRepository"/>:
/// every overwriting save of a TenantConfiguration/AdminConfiguration row must first
/// snapshot the stored row into ConfigurationBackups — except row creations and
/// noise-only changes — and a backup-storage failure must never break the save (fail-soft).
/// </summary>
public class ConfigBackupHookTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private sealed class Harness
{
public TableConfigRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public List<TableEntity> Upserts { get; } = new();
public Harness(TableEntity? stored)
{
if (stored == null)
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
}
else
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
}
Table.Setup(c => c.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
Backup.Object,
NullLogger<TableConfigRepository>.Instance);
}
}
private static TenantConfiguration StoredConfig()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.DomainName = "contoso.com";
config.UpdatedBy = "admin@contoso.com";
config.DataRetentionDays = 30;
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
return config;
}
/// <summary>
/// Mirrors production: every real writer round-trips the STORED row (read-modify-write),
/// so time-stamped fields like OnboardedAt match the stored values exactly. Constructing
/// a second CreateDefault instead would fabricate an OnboardedAt diff that cannot occur.
/// </summary>
private static TenantConfiguration RoundtripClone(TenantConfiguration stored)
=> TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(stored));
[Fact]
public async Task RealChange_SnapshotsStoredRow_ThenSaves_ThenPrunes()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
incoming.UpdatedBy = "ga@operator.example";
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
Assert.True(saved);
Assert.Single(harness.Upserts); // the config row itself
Assert.NotNull(snapshot);
Assert.Equal(TenantId, snapshot!.PartitionKey);
Assert.Equal("portal-put", snapshot.Source);
Assert.Equal("test change", snapshot.Reason);
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
// The snapshot holds the STORED row (retention 30), not the incoming one.
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
harness.Backup.Verify(
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RowCreation_NoExistingRow_NoSnapshot()
{
var harness = new Harness(stored: null);
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
harness.Backup.Verify(
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
// The auth-side-effect write: only LastAuthClientId/Since + bookkeeping move.
var incoming = RoundtripClone(stored);
incoming.LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
incoming.LastAuthClientIdSince = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
incoming.UpdatedBy = "System (auth)";
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
Assert.True(saved); // the save itself still goes through
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoOpSave_IdenticalContent_NoSnapshot()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
Assert.True(saved);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task BackupStorageFailure_IsFailSoft_SaveStillSucceeds()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "backup table throttled"));
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
Assert.True(saved);
Assert.Single(harness.Upserts); // the config write went through regardless
}
[Fact]
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
await harness.Sut.SaveTenantConfigurationAsync(incoming);
Assert.NotNull(snapshot);
Assert.Equal("unknown", snapshot!.Source);
}
[Fact]
public async Task AdminConfigSave_SnapshotsUnderGlobalConfigPartition()
{
var stored = new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 100,
};
var harness = new Harness(TableConfigRepository.ConvertToAdminTableEntity(stored));
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var incoming = new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 200,
};
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
Assert.True(saved);
Assert.NotNull(snapshot);
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
Assert.Equal("admin-config", snapshot.Source);
harness.Backup.Verify(
b => b.PruneAsync("GlobalConfig", Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
Times.Once);
}
}
@@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"),
/// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics.
/// </summary>
public class ConfigBackupRepositoryTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private sealed class Harness
{
public TableConfigBackupRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public List<TableEntity> Upserts { get; } = new();
public List<(string Pk, string Rk)> Deletes { get; } = new();
public Harness()
{
Table.Setup(c => c.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.DeleteEntityAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
{
Deletes.Add((pk, rk));
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableConfigBackupRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableConfigBackupRepository>.Instance);
}
public void SetupQuery(params TableEntity[] rows)
{
var page = Page<TableEntity>.FromValues(rows, continuationToken: null, Mock.Of<Response>());
Table.Setup(c => c.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
}
}
private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new()
{
PartitionKey = TenantId,
RowKey = rowKey,
TenantId = TenantId,
EntityJson = "{\"DomainName\":\"contoso.com\"}",
ChangedBy = "ga@operator.example",
Source = "mcp-patch",
Reason = "retention bump",
DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}",
BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Roundtrip_StoreAndMap_PreservesEveryField()
{
var harness = new Harness();
var entry = Entry();
await harness.Sut.UpsertAsync(entry);
var stored = Assert.Single(harness.Upserts);
// Feed the stored entity back through the read path (TryGetAsync → Map).
harness.Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey);
Assert.NotNull(mapped);
Assert.Equal(entry.PartitionKey, mapped!.PartitionKey);
Assert.Equal(entry.RowKey, mapped.RowKey);
Assert.Equal(entry.TenantId, mapped.TenantId);
Assert.Equal(entry.EntityJson, mapped.EntityJson);
Assert.Equal(entry.ChangedBy, mapped.ChangedBy);
Assert.Equal(entry.Source, mapped.Source);
Assert.Equal(entry.Reason, mapped.Reason);
Assert.Equal(entry.DiffJson, mapped.DiffJson);
Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt);
}
[Fact]
public void BuildRowKey_LaterTimestamps_SortFirst()
{
var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc));
var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc));
// Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so
// Azure's RowKey-ascending partition scan returns newest-first.
Assert.True(string.CompareOrdinal(later, earlier) < 0);
}
[Fact]
public void BuildRowKey_SameTick_DistinctViaGuidSuffix()
{
var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
Assert.NotEqual(
TableConfigBackupRepository.BuildRowKey(at),
TableConfigBackupRepository.BuildRowKey(at));
}
[Fact]
public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder()
{
var harness = new Harness();
// Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must
// delete rows 3 and 4, never the two newest.
harness.SetupQuery(
new TableEntity(TenantId, "0001_newest"),
new TableEntity(TenantId, "0002_second"),
new TableEntity(TenantId, "0003_old"),
new TableEntity(TenantId, "0004_oldest"));
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
Assert.Equal(2, deleted);
Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray());
}
[Fact]
public async Task Prune_WithinKeep_DeletesNothing()
{
var harness = new Harness();
harness.SetupQuery(
new TableEntity(TenantId, "0001_newest"),
new TableEntity(TenantId, "0002_second"));
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
Assert.Equal(0, deleted);
Assert.Empty(harness.Deletes);
}
[Fact]
public async Task ListByPartition_CapsAtMax()
{
var harness = new Harness();
harness.SetupQuery(
new TableEntity(TenantId, "0001"),
new TableEntity(TenantId, "0002"),
new TableEntity(TenantId, "0003"));
var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2);
Assert.Equal(2, listed.Count);
Assert.Equal("0001", listed[0].RowKey);
}
}
@@ -192,4 +192,37 @@ public class ConfigRedactionTests
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
// ── ?view=redacted opt-in (MCP get_tenant_config): forces redaction even for GA ──
[Fact]
public void ShouldRedact_GlobalAdmin_ViewRedacted_True()
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
"?view=redacted"));
[Fact]
public void ShouldRedact_OwnTenantAdmin_ViewRedacted_True()
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
new RequestContext { IsTenantAdmin = true, TenantId = TenantA, TargetTenantId = TenantA },
"?view=redacted"));
[Fact]
public void ShouldRedact_GlobalAdmin_NoParam_False()
// Without the opt-in the GA clearance stands — the portal Settings UI depends on it.
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
""));
[Fact]
public void ShouldRedact_GlobalAdmin_OtherViewValue_False()
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
"?view=full"));
[Fact]
public void ShouldRedact_ReaderWithoutClearance_AlwaysTrue_RegardlessOfParam()
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
new RequestContext { IsGlobalReader = true, TenantId = TenantA, TargetTenantId = TenantB },
"?view=full"));
}
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
Assert.Equal(16, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins)
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
@@ -44,7 +44,7 @@ public class DelegatedAdminEditionGateTests
return new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
}
@@ -118,7 +118,7 @@ public class DelegatedAdminEditionGateTests
var svc = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
@@ -36,7 +36,7 @@ public class DelegatedAdminServiceTests
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
var svc = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
@@ -120,6 +120,77 @@ public class DistressValidationTests
Assert.Equal("203.0.113.5", ClientIpExtractor.ExtractTrustedHop("10.0.0.1, 203.0.113.5, "));
}
// =========================================================================
// ClientIpExtractor.GetHeaderFromBindingDataJson — Flex Consumption strips the
// X-Forwarded-* family from the proxied HTTP request; the host passes the
// ORIGINAL header set as trigger metadata (BindingData["Headers"], JSON object).
// Malformed metadata must fail soft (null), never throw into request handling.
// =========================================================================
[Fact]
public void GetHeaderFromBindingDataJson_XffPresent_ReturnsValue()
{
var json = """{"Host":"example.com","X-Forwarded-For":"1.1.1.1, 203.0.113.5:443"}""";
Assert.Equal(
"1.1.1.1, 203.0.113.5:443",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Fact]
public void GetHeaderFromBindingDataJson_HeaderNameCaseInsensitive()
{
var json = """{"x-forwarded-for":"203.0.113.5"}""";
Assert.Equal(
"203.0.113.5",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")] // JSON but not an object
[InlineData("""{"Host":"example.com"}""")] // header absent
[InlineData("""{"X-Forwarded-For":42}""")] // non-string value
public void GetHeaderFromBindingDataJson_MissingOrMalformed_ReturnsNull(string? json)
{
Assert.Null(ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
// =========================================================================
// ClientIpExtractor.RemoteIpToString — transport-level fallback. On Flex the
// worker's TCP peer is the host's LOCAL proxy: loopback means "not a client"
// and must map to "unknown", never be stored as a forensic SourceIp
// (production 2026-07-29: distress reports carried SourceIp 127.0.0.1).
// =========================================================================
[Fact]
public void RemoteIpToString_Null_ReturnsUnknown()
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(null));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
public void RemoteIpToString_Loopback_ReturnsUnknown(string ip)
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse(ip)));
}
[Fact]
public void RemoteIpToString_PublicIpv4_ReturnsIp()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("203.0.113.5")));
}
[Fact]
public void RemoteIpToString_Ipv4MappedIpv6_FoldsToIpv4()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("::ffff:203.0.113.5")));
}
// =========================================================================
// Sanitize — Control character stripping and truncation
// =========================================================================
@@ -0,0 +1,133 @@
using System.Collections.Generic;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Resolution matrix for the dual app-registration window (see EntraAppRegistry):
/// legacy unconfigured ⇒ always primary (code deploy is a no-op before the config swap);
/// null homing ⇒ legacy; known ids ⇒ that app; unknown ⇒ primary fallback.
/// </summary>
public class EntraAppRegistryTests
{
private const string PrimaryId = "886ab5e2-6144-442c-80cc-9b28e0667731";
private const string LegacyId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
private static EntraAppRegistry Build(Dictionary<string, string?> settings) =>
new(new ConfigurationBuilder().AddInMemoryCollection(settings).Build(),
NullLogger<EntraAppRegistry>.Instance);
private static EntraAppRegistry DualApp() => Build(new()
{
["EntraId:ClientId"] = PrimaryId,
["EntraId:ClientSecret"] = "primary-secret",
["EntraId:LegacyClientId"] = LegacyId,
["EntraId:LegacyClientSecret"] = "legacy-secret",
});
private static TenantConfiguration Tenant(string? homedAppClientId) => new()
{
TenantId = "11111111-1111-1111-1111-111111111111",
HomedAppClientId = homedAppClientId,
};
[Fact]
public void LegacyUnconfigured_AlwaysResolvesPrimary_EvenForNullHoming()
{
var registry = Build(new()
{
["EntraId:ClientId"] = PrimaryId,
["EntraId:ClientSecret"] = "primary-secret",
});
Assert.False(registry.LegacyConfigured);
Assert.Equal(PrimaryId, registry.ResolveForTenant(Tenant(null)).ClientId);
Assert.Equal(PrimaryId, registry.ResolveForTenant(null).ClientId);
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
}
[Fact]
public void NullHoming_ResolvesLegacy()
{
var resolved = DualApp().ResolveForTenant(Tenant(null));
Assert.True(resolved.IsLegacy);
Assert.Equal(LegacyId, resolved.ClientId);
Assert.Equal("legacy-secret", resolved.ClientSecret);
}
[Fact]
public void MissingConfigRow_ResolvesLegacy_SameAsNullHoming()
{
Assert.True(DualApp().ResolveForTenant(null).IsLegacy);
}
[Fact]
public void PrimaryHoming_ResolvesPrimary_CaseAndBraceInsensitive()
{
var registry = DualApp();
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId)).IsLegacy);
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId.ToUpperInvariant())).IsLegacy);
Assert.False(registry.ResolveForTenant(Tenant("{" + PrimaryId + "}")).IsLegacy);
}
[Fact]
public void LegacyHoming_ResolvesLegacy()
{
var resolved = DualApp().ResolveForTenant(Tenant(LegacyId));
Assert.True(resolved.IsLegacy);
Assert.Equal("legacy-secret", resolved.ClientSecret);
}
[Fact]
public void UnknownHoming_FallsBackToPrimary()
{
var resolved = DualApp().ResolveForTenant(Tenant("99999999-9999-9999-9999-999999999999"));
Assert.False(resolved.IsLegacy);
Assert.Equal(PrimaryId, resolved.ClientId);
}
[Fact]
public void MalformedLegacyClientId_TreatedAsUnconfigured()
{
var registry = Build(new()
{
["EntraId:ClientId"] = PrimaryId,
["EntraId:LegacyClientId"] = "not-a-guid",
["EntraId:LegacyClientSecret"] = "legacy-secret",
});
Assert.False(registry.LegacyConfigured);
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
}
[Theory]
[InlineData(PrimaryId, true)]
[InlineData("api://" + PrimaryId, true)]
[InlineData("API://" + PrimaryId, true)]
[InlineData(LegacyId, false)]
[InlineData("api://" + LegacyId, false)]
[InlineData("https://graph.microsoft.com", false)]
[InlineData(null, false)]
[InlineData("", false)]
public void IsPrimary_MatchesBareAndApiPrefixedAudiences(string? audience, bool expected)
{
Assert.Equal(expected, DualApp().IsPrimary(audience));
}
[Theory]
[InlineData("api://" + LegacyId, LegacyId)]
[InlineData(LegacyId, LegacyId)]
[InlineData("API://886AB5E2-6144-442C-80CC-9B28E0667731", PrimaryId)]
[InlineData("https://graph.microsoft.com", null)] // non-GUID audience must never persist as provenance
[InlineData("api://not-a-guid", null)]
[InlineData(null, null)]
[InlineData(" ", null)]
public void NormalizeAudience_StripsApiPrefixAndRequiresGuid(string? audience, string? expected)
{
Assert.Equal(expected, EntraAppRegistry.NormalizeAudience(audience));
}
}
@@ -6,8 +6,9 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the edition-resolution matrix and the per-edition entitlement values of
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
/// null/empty and unknown values all resolve to Community without any data migration.
/// "pro" / the legacy stored value "enterprise" (or an active trial) yields Pro — the legacy
/// stored tier "free", null/empty and unknown values all resolve to Community without any
/// data migration.
/// </summary>
public class FeatureEntitlementCatalogTests
{
@@ -16,31 +17,34 @@ public class FeatureEntitlementCatalogTests
// ── ResolveEdition matrix ────────────────────────────────────────────────────
[Theory]
[InlineData("enterprise")]
[InlineData("pro")]
[InlineData("Pro")]
[InlineData("PRO")]
[InlineData(" pro ")]
[InlineData("enterprise")] // legacy stored value — must stay readable as Pro
[InlineData("Enterprise")]
[InlineData("ENTERPRISE")]
[InlineData(" enterprise ")]
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
{
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
}
[Theory]
[InlineData("free")]
[InlineData("pro")]
[InlineData("community")]
[InlineData("")]
[InlineData(null)]
[InlineData("premium")] // unknown value → fail-closed
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
{
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
}
[Fact]
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
{
Assert.Equal(TenantEdition.Enterprise,
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
}
@@ -59,11 +63,28 @@ public class FeatureEntitlementCatalogTests
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
}
[Fact]
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
[Theory]
[InlineData("pro")]
[InlineData("enterprise")]
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
{
Assert.Equal(TenantEdition.Enterprise,
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
}
// ── IsPermanentProTier ───────────────────────────────────────────────────────
[Theory]
[InlineData("pro", true)]
[InlineData(" PRO ", true)]
[InlineData("enterprise", true)] // legacy stored value
[InlineData("free", false)]
[InlineData("community", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsPermanentProTier_Matrix(string? tier, bool expected)
{
Assert.Equal(expected, FeatureEntitlementCatalog.IsPermanentProTier(tier));
}
// ── Entitlement values ───────────────────────────────────────────────────────
@@ -76,20 +97,24 @@ public class FeatureEntitlementCatalogTests
Assert.Null(e.UserRateLimitPerMinute);
Assert.Null(e.DeviceRateLimitPerMinute);
Assert.False(e.DelegatedAdminAllowed);
Assert.False(e.BootstrapIncluded);
Assert.False(e.UnrestrictedModeAvailable);
Assert.Equal("community", e.McpUsagePlanName);
Assert.Equal(100, e.McpDailyRequestLimit);
Assert.Equal(3000, e.McpMonthlyRequestLimit);
}
[Fact]
public void Enterprise_Entitlements_MatchMatrix()
public void Pro_Entitlements_MatchMatrix()
{
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
var e = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
Assert.Equal(365, e.RetentionCapDays);
Assert.Equal(150, e.UserRateLimitPerMinute);
Assert.Equal(150, e.DeviceRateLimitPerMinute);
Assert.True(e.DelegatedAdminAllowed);
Assert.Equal("enterprise", e.McpUsagePlanName);
Assert.True(e.BootstrapIncluded);
Assert.True(e.UnrestrictedModeAvailable);
Assert.Equal("pro", e.McpUsagePlanName);
Assert.Equal(1000, e.McpDailyRequestLimit);
Assert.Equal(20000, e.McpMonthlyRequestLimit);
}
@@ -0,0 +1,153 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
/// the OOBE bootstrap script endpoint reached by customers as
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
/// the no-store cache classification — are pinned here. Script generation and value
/// validation have their own suites.
/// </summary>
public class GetBootstrapScriptFunctionTests
{
private const string Route = "/api/bootstrap/go/abcd";
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
// Table ported from the vitest suite of the original Next.js route. ──
[Theory]
[InlineData("")]
[InlineData("abc")] // too short
[InlineData("abcdefghijk")] // too long
[InlineData("ab cd")]
[InlineData("../etc")]
[InlineData("ab%2Fcd")]
[InlineData("abc$")]
[InlineData("ab'cd")]
[InlineData("ab.cd")]
public void Rejects_invalid_code_formats(string code)
{
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
[Theory]
[InlineData("abcd")]
[InlineData("ABC123")]
[InlineData("a1b2c3d4e5")]
public void Accepts_valid_code_formats(string code)
{
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
// so this pins reachability. ──
[Fact]
public void Route_is_registered_as_PublicAnonymous()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
}
[Fact]
public void Route_is_jwt_exempt()
{
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
}
// ── The script body inlines the bearer token — must never be cached. ──
[Fact]
public void Route_is_classified_no_store()
{
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
}
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
// someone appended to what AFD set — untrusted, fail closed. ──
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
[Theory]
[InlineData(new[] { Fdid }, true)]
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
[InlineData(new[] { "other-profile-id" }, false)]
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
[InlineData(new[] { "spoofed," + Fdid }, false)]
[InlineData(new string[0], false)]
[InlineData(new[] { "" }, false)]
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
{
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
}
}
/// <summary>
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
/// previously unguarded, which made a silent origin change possible. The literal here
/// is a deliberate independent oracle per the url-registry doctrine (test files are
/// excluded from HardcodedUrlGuardTests).
/// </summary>
public class CreateBootstrapSessionUrlShapeTests
{
[Fact]
public void BootstrapGoBaseUrl_is_the_go_domain()
{
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
}
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
// query-string shape the static-export portal understands. Literal is a
// deliberate independent oracle; the legacy /sessions/{id} path shape is
// rewritten client-side by the portal forever. ──
[Fact]
public void PortalSessionUrl_is_the_query_string_shape()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
Constants.PortalSessionUrl("abc-123"));
}
[Fact]
public void PortalSessionUrl_escapes_the_session_id()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
Constants.PortalSessionUrl("a&b=c"));
}
[Fact]
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
{
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
// injectable seam): the create function must derive BootstrapUrl from
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
// path segment between host and code.
var repoRoot = FindRepoRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
Assert.DoesNotContain("WebsiteBaseUrl}/go/", source);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -34,7 +34,13 @@ public class GetTenantFeatureFlagsPayloadTests
// — and that forces a deliberate code review of whether the new field is non-sensitive.
Assert.Equal(new[]
{
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
// self-service migration banner).
"appHomingFunnelActive",
"bootstrapTokenEnabled",
// Non-sensitive by review: bool only ("is a contact address set"), never the
// address itself (admin-gated full config only).
"contactEmailSet",
"diagnosticsUploadConfigured",
"edition",
"enableIntegrityBypassAnalyzer",
@@ -54,11 +60,13 @@ public class GetTenantFeatureFlagsPayloadTests
{
var config = new TenantConfiguration
{
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
BootstrapTokenEnabled = true,
ValidateAutopilotDevice = true,
ShowScriptOutput = false,
EnableSoftwareInventoryAnalyzer = true,
EnableIntegrityBypassAnalyzer = false,
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
};
@@ -72,6 +80,55 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
{
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
{
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
// effective value must be false.
var element = Serialize(new TenantConfiguration
{
PlanTier = "free",
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
{
var element = Serialize(new TenantConfiguration
{
PlanTier = "pro",
UnrestrictedModeEnabled = false,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
{
@@ -159,12 +216,14 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
}
[Fact]
public void Payload_EnterpriseTier_NotTrial()
[Theory]
[InlineData("pro")]
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
public void Payload_PermanentProTier_NotTrial(string storedTier)
{
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
@@ -178,7 +237,7 @@ public class GetTenantFeatureFlagsPayloadTests
var expiry = Now.AddDays(10);
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.True(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
@@ -193,4 +252,27 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
}
// ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ──
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("it-ops@contoso.com", true)]
public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected)
{
var element = Serialize(new TenantConfiguration { ContactEmail = stored });
Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean());
}
[Fact]
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
{
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
Assert.DoesNotContain("secret-contact", json);
}
}
@@ -327,17 +327,23 @@ public class GraphFeatureDetectorTests
var handler = new SwappableTokenHandler(
BuildJwt(Array.Empty<Claim>(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
var cache = new MemoryCache(new MemoryCacheOptions());
var tokenConfig = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var tokenService = new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
new SingleHandlerFactory(handler),
cache,
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build());
tokenConfig,
new EntraAppRegistry(tokenConfig, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
@@ -454,6 +460,28 @@ public class GraphFeatureDetectorTests
// ── Test seam: token service stub + detector factory ──────────────────────────────
/// <summary>Minimal EntraId config for the GraphTokenService base ctor (no legacy app).</summary>
private static Microsoft.Extensions.Configuration.IConfiguration StubEntraConfig() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client-id",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
/// <summary>
/// Dual app-reg dependencies for the GraphTokenService base ctor. No legacy app configured
/// ⇒ the registry always resolves primary and the tenant-config lookup is never consulted.
/// </summary>
private static EntraAppRegistry StubAppRegistry() =>
new(StubEntraConfig(), NullLogger<EntraAppRegistry>.Instance);
private static AutopilotMonitor.Functions.Services.TenantConfigurationService StubTenantConfigService() =>
new(Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
/// <summary>
/// Subclass that bypasses the real AAD HTTP call so tests can drive the detector with
/// canned <see cref="GraphTokenResult"/> values. Base ctor dependencies are satisfied
@@ -469,13 +497,9 @@ public class GraphFeatureDetectorTests
NullLogger<GraphTokenService>.Instance,
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client-id",
["EntraId:ClientSecret"] = "test-secret",
})
.Build())
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
}
@@ -506,13 +530,9 @@ public class GraphFeatureDetectorTests
NullLogger<GraphTokenService>.Instance,
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client-id",
["EntraId:ClientSecret"] = "test-secret",
})
.Build())
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
_firstToken = firstToken;
_laterToken = laterToken;
@@ -549,13 +569,9 @@ public class GraphFeatureDetectorTests
NullLogger<GraphTokenService>.Instance,
factory,
cache,
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client-id",
["EntraId:ClientSecret"] = "test-secret",
})
.Build())
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
_onInvalidate = onInvalidate;
}
@@ -33,15 +33,28 @@ public class GraphTokenServiceTests
})
.Build();
var svc = new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
new StubHttpClientFactory(handler),
new MemoryCache(new MemoryCacheOptions()),
config);
var svc = NewService(config, new StubHttpClientFactory(handler));
return (svc, handler);
}
/// <summary>
/// Builds the service with the dual app-reg dependencies. No legacy app is configured in
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
/// lookup is never consulted for the decision — a null-returning repo is fine.
/// </summary>
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
config,
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
@@ -148,11 +161,7 @@ public class GraphTokenServiceTests
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var svc = new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
new SingleHandlerFactory(handler),
new MemoryCache(new MemoryCacheOptions()),
config);
var svc = NewService(config, new SingleHandlerFactory(handler));
// R1 starts; its POST parks in flight (holding the gate, before writing to the cache).
var inflight = svc.GetAccessTokenAsync(TenantA);
@@ -188,11 +197,7 @@ public class GraphTokenServiceTests
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var svc = new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
new SingleHandlerFactory(handler),
new MemoryCache(new MemoryCacheOptions()),
config);
var svc = NewService(config, new SingleHandlerFactory(handler));
var tasks = Enumerable.Range(0, 10).Select(_ => svc.GetAccessTokenAsync(TenantA)).ToArray();
@@ -209,6 +214,54 @@ public class GraphTokenServiceTests
Assert.Equal(1, handler.Entered);
}
[Fact]
public async Task GetAccessTokenForAppAsync_caches_under_the_supplied_app_without_cross_app_poisoning()
{
// Dual app-reg window: the homing-flip consent probe mints under EXPLICIT primary
// credentials for a still-legacy-homed tenant. Contract: the probe token caches under the
// primary key, a second probe is served from cache, and the tenant's normal (legacy-
// resolved) path still mints its own token — never the probe's.
var handler = new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, "{\"access_token\":\"tok\",\"expires_in\":3599}");
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
["EntraId:ClientSecret"] = "primary-secret",
["EntraId:LegacyClientId"] = "bbbbbbbb-0000-0000-0000-000000000002",
["EntraId:LegacyClientSecret"] = "legacy-secret",
})
.Build();
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
var svc = new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
new StubHttpClientFactory(handler),
new MemoryCache(new MemoryCacheOptions()),
config,
registry,
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
Assert.Equal("tok", probe1.AccessToken);
Assert.Equal("tok", probe2.AccessToken);
Assert.Equal(1, TokenPosts(handler, TenantA)); // second probe served from the primary-keyed cache
// Normal path resolves LEGACY for this tenant (no homing stored) — must be a cache miss
// against the probe's primary-keyed entry and mint its own token.
var homed = await svc.GetAccessTokenAsync(TenantA);
Assert.Equal("tok", homed.AccessToken);
Assert.Equal(2, TokenPosts(handler, TenantA));
// InvalidateTenant drops BOTH apps' entries — the next probe re-mints fresh.
svc.InvalidateTenant(TenantA);
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
Assert.Equal(3, TokenPosts(handler, TenantA));
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -26,6 +26,7 @@ public class HardcodedUrlGuardTests
"www.autopilotmonitor.com",
"docs.autopilotmonitor.com",
"download.autopilotmonitor.com",
"go.autopilotmonitor.com",
"mcp.autopilotmonitor.com",
"autopilotmonitor-api-eu.azurewebsites.net",
"autopilotmonitor.blob.core.windows.net",
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
@@ -22,9 +23,11 @@ public class HealthCheckServicePoisonQueueTests
[Fact]
public async Task Check_AllPoisonQueuesEmpty_ReportsHealthy()
{
// Existing-but-drained poison queues: enumerated, probed, all zero.
var probe = new FakePoisonQueueProbe();
foreach (var q in MaintenanceService.MonitoredPoisonQueues)
probe.Counts[q] = 0;
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
@@ -32,10 +35,40 @@ public class HealthCheckServicePoisonQueueTests
Assert.Equal("Poison Queues", check.Name);
Assert.Equal("healthy", check.Status);
Assert.Equal("All poison queues empty", check.Message);
Assert.Equal(MaintenanceService.MonitoredPoisonQueues.Length, check.Details!.Count);
Assert.Equal(3, check.Details!.Count);
Assert.All(check.Details.Values, v => Assert.Equal("0 messages", v));
}
[Fact]
public async Task Check_NoPoisonQueuesExist_ReportsHealthy()
{
// Steady state on a healthy system: poison queues are created lazily on
// first poison-move, so an empty enumeration means nothing ever failed.
var svc = BuildService(new FakePoisonQueueProbe());
var check = await svc.CheckPoisonQueuesAsync();
Assert.Equal("healthy", check.Status);
Assert.Equal("All poison queues empty", check.Message);
Assert.Empty(check.Details!);
}
[Fact]
public async Task Check_EnumerationFails_ReportsWarningWithError()
{
// A failed listing must never read as an all-clear.
var probe = new FakePoisonQueueProbe
{
ListException = new InvalidOperationException("auth denied")
};
var svc = BuildService(probe);
var check = await svc.CheckPoisonQueuesAsync();
Assert.Equal("warning", check.Status);
Assert.Contains("enumeration failed", check.Message);
Assert.Contains("auth denied", (string)check.Details!["error"]);
}
[Fact]
public async Task Check_SingleQueueWithBacklog_ReportsWarning()
{
@@ -155,6 +188,19 @@ public class HealthCheckServicePoisonQueueTests
{
public Dictionary<string, long> Counts { get; } = new();
public Dictionary<string, Exception> ExceptionFor { get; } = new();
public Exception? ListException { get; set; }
public Task<IReadOnlyList<string>> ListPoisonQueuesAsync(CancellationToken ct)
{
if (ListException is not null)
throw ListException;
IReadOnlyList<string> names = Counts.Keys
.Concat(ExceptionFor.Keys)
.Distinct()
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
return Task.FromResult(names);
}
public Task<long> GetApproximateMessageCountAsync(string queueName, CancellationToken ct)
{
@@ -1,6 +1,5 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
@@ -10,7 +9,9 @@ namespace AutopilotMonitor.Functions.Tests;
/// of the parts that decide whether an operator gets paged, without standing up
/// the full MaintenanceService dependency graph. Full-service integration is
/// exercised by the live timer + ops event tables — what we guard here is the
/// classifier math, the dedup-key extractor, and the watch-list inventory.
/// classifier math and the dedup-key extractor. The watched queues themselves are
/// enumerated dynamically at runtime (every existing <c>-poison</c> queue), so
/// there is no static inventory left to guard.
/// </summary>
public class MaintenanceServicePoisonQueueTests
{
@@ -116,36 +117,6 @@ public class MaintenanceServicePoisonQueueTests
Assert.Equal(string.Empty, MaintenanceService.ExtractQueueName(detailsJson));
}
// --- Watch-list inventory ---
[Fact]
public void MonitoredPoisonQueues_CoversAllProducerQueues()
{
// Guard against silently adding a new producer queue without a poison entry.
// If a new queue is introduced, MonitoredPoisonQueues + this assertion must move together.
Assert.Equal(6, MaintenanceService.MonitoredPoisonQueues.Length);
Assert.Contains(
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
MaintenanceService.MonitoredPoisonQueues);
Assert.Contains(
Constants.QueueNames.VulnerabilityCorrelate + "-poison",
MaintenanceService.MonitoredPoisonQueues);
Assert.Contains(
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
MaintenanceService.MonitoredPoisonQueues);
// PR1 critical-table backup queue (plan §Wave5 #2).
Assert.Contains(
Constants.QueueNames.CriticalTableBackupPoison,
MaintenanceService.MonitoredPoisonQueues);
// Self-hosted poll-loop workers (no QueueTrigger) — see SessionDeletionWorker / TenantOffboardingWorker.
Assert.Contains(
Constants.QueueNames.SessionDeletion + "-poison",
MaintenanceService.MonitoredPoisonQueues);
Assert.Contains(
Constants.QueueNames.TenantOffboardingPoison,
MaintenanceService.MonitoredPoisonQueues);
}
[Fact]
public void Defaults_AreCalibratedForStrictMode()
{
@@ -131,14 +131,14 @@ public class McpQuotaServiceTests
}
[Fact]
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
public async Task Check_ProEdition_UsesProFallbackLimits()
{
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("enterprise", d.Plan);
Assert.Equal("pro", d.Plan);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(20000, d.MonthlyLimit);
}
@@ -36,7 +36,7 @@ public class McpUserServiceDelegatedTests
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
_delegatedAdmin = new Mock<DelegatedAdminService>(
_adminRepo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
_adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
@@ -180,10 +180,14 @@ public class OffboardingMarkerCleanupFunctionTests
Assert.Equal(0, result.TenantConfigsSpared);
Assert.Equal(0, result.TenantConfigSweepRetries);
// The sweep ran with the right table + tenant id.
Assert.Single(fixture.SafeWipe.WipeCalls);
// The sweep ran with the right tables + tenant id: the tombstone itself, plus the
// tenant's config-backup partition (a save against the lingering tombstone may have
// re-created snapshots after the cascade's bulk wipe).
Assert.Equal(2, fixture.SafeWipe.WipeCalls.Count);
Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].TenantId);
Assert.Equal(Constants.TableNames.ConfigurationBackups, fixture.SafeWipe.WipeCalls[1].Table);
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[1].TenantId);
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
}
@@ -279,6 +279,20 @@ public class TenantOffboardingHandlerOrderingTests
Assert.Contains(Constants.TableNames.TenantGroups, harness.SafeWipeProbe.PropertyOnlyWipes);
}
[Fact]
public async Task PostDrain_ExactPartitionWipe_CoversConfigurationBackups()
{
// Pre-write config snapshots (PK=tenantId) must die with the tenant — the row
// contents include webhook URLs and the contact address. The bulk wipe runs
// before the final TenantConfiguration delete, so no snapshot can be re-created
// afterwards by the cascade itself.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
}
// ── Harness (copied minimal — only what these tests need) ───────────────────
private sealed class Harness
@@ -425,12 +439,14 @@ public class TenantOffboardingHandlerOrderingTests
public int WipeCallCount { get; private set; }
/// <summary>Table names passed to the property-only (Variant C) wipe — lets tests assert coverage.</summary>
public List<string> PropertyOnlyWipes { get; } = new();
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
public List<string> ExactPartitionWipes { get; } = new();
public CountingSafeWipeService() : base(
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
NullLogger<BlobStorageService>.Instance, usesManagedIdentity: false),
NullLogger<SafeWipeService>.Instance) { }
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
@@ -0,0 +1,112 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the OOBE bootstrap script generator, ported from the portal's
/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line
/// endings) with the original TypeScript template output — the reference file
/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template
/// literal with the same fixed inputs. Ports the behavioral cases of the vitest
/// suite app/go/[code]/__tests__/route.test.ts that concern script generation.
/// </summary>
public class OobeBootstrapScriptGeneratorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Token = "22222222-2222-2222-2222-222222222222";
private const string DownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static BootstrapScriptValueValidator.ValidatedValues FixedValues() =>
new(TenantId, Token, DownloadUrl,
new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
private static string Generate() =>
OobeBootstrapScriptGenerator.GenerateSuccessScript(
FixedValues(),
new DateTime(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc));
// ── Golden parity with the original Next.js template ──
[Fact]
public void SuccessScript_matches_the_typescript_reference_output()
{
var goldenPath = Path.Combine(AppContext.BaseDirectory, "TestData", "oobe-bootstrap-golden.ps1");
var golden = File.ReadAllText(goldenPath);
var actual = Generate();
// Normalize line endings on both sides: git checkout / editors may flip
// LF↔CRLF in either the .cs raw string or the golden asset; PowerShell
// accepts both. Content parity is what the test pins.
static string Normalize(string s) => s.Replace("\r\n", "\n");
Assert.Equal(Normalize(golden), Normalize(actual));
}
// ── Success-path markers (vitest parity) ──
[Fact]
public void SuccessScript_contains_the_expected_markers()
{
var script = Generate();
Assert.StartsWith("#Requires -RunAsAdministrator", script);
Assert.Contains($"--tenant-id '{TenantId}'", script);
Assert.Contains($"--bootstrap-token '{Token}'", script);
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
Assert.Contains("https://portal.autopilotmonitor.com", script);
}
// ── SECURITY INVARIANT: every substituted value sits inside a single-quoted
// PS literal (single-quoted strings do not expand $(), $var, or backticks).
// The expiry timestamp appears twice: once in the plain-text comment block
// and once quoted in the Write-Log line. ──
[Fact]
public void SuccessScript_frames_all_interpolated_values_in_single_quotes()
{
var script = Generate();
Assert.Contains($"'{DownloadUrl}'", script);
Assert.Contains($"'{Token}'", script);
Assert.Contains($"'{TenantId}'", script);
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
}
// ── Error script: message surfaces via Write-Host, always as a script ──
[Fact]
public void ErrorScript_contains_the_message()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format.");
Assert.Contains("ERROR: Invalid bootstrap code format.", script);
Assert.Contains("Write-Host", script);
}
[Fact]
public void ErrorScript_doubles_single_quotes_for_powershell()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("it's a 'bad' code");
Assert.Contains("it''s a ''bad'' code", script);
}
// ── Cap is applied to the RAW message before quote-escaping, so a
// quote-heavy message cannot double past the cap. ──
[Fact]
public void ErrorScript_caps_the_message_at_200_chars_before_escaping()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('X', 300));
Assert.Contains(new string('X', 200) + "...", script);
Assert.DoesNotContain(new string('X', 201), script);
}
[Fact]
public void ErrorScript_cap_counts_raw_length_not_escaped_length()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('\'', 300));
// 200 raw quotes escape to 400 — allowed, because the cap is pre-escape.
Assert.Contains(new string('\'', 400) + "...", script);
}
}
@@ -0,0 +1,76 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="PlanManagementFunction.EvaluateTrialStart"/> — the self-service
/// trial verdict, including the Pro-requires-contact gate. The check ORDER is semantic
/// and pinned here: terminal conditions (consumed / already Pro) must win over the
/// contact-address prompt, and the contact gate exists only at this entry point (never
/// as a runtime lockout for existing Pro tenants).
/// </summary>
public class PlanManagementTrialGateTests
{
private static readonly DateTime Now = new(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc);
private const string Contact = "it-ops@contoso.com";
[Fact]
public void Allowed_WhenCommunityWithContactAndUnconsumedTrial()
{
var config = new TenantConfiguration { ContactEmail = Contact };
Assert.Null(PlanManagementFunction.EvaluateTrialStart(config, Now));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Denied_ContactEmailRequired_WhenNoAddressStored(string? contact)
{
var config = new TenantConfiguration { ContactEmail = contact };
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
Assert.Equal("ContactEmailRequired", deny?.Error);
}
[Fact]
public void Denied_TrialAlreadyConsumed_WinsOverMissingContact()
{
// A consumed trial is terminal — prompting for a contact address would be pointless.
var config = new TenantConfiguration { TrialConsumed = true, ContactEmail = null };
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
Assert.Equal("TrialAlreadyConsumed", deny?.Error);
}
[Fact]
public void Denied_AlreadyPro_WinsOverMissingContact()
{
// Existing Pro tenants without a contact address are nagged via the dashboard
// banner, never blocked — the verdict must name the Pro state, not the address.
var config = new TenantConfiguration { PlanTier = "pro", ContactEmail = null };
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
Assert.Equal("AlreadyPro", deny?.Error);
}
[Fact]
public void Denied_AlreadyPro_ForActiveTrialEdition()
{
var config = new TenantConfiguration
{
TrialExpiresUtc = Now.AddDays(5),
TrialConsumed = false, // hypothetical shape — edition resolution alone must deny
ContactEmail = Contact,
};
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
Assert.Equal("AlreadyPro", deny?.Error);
}
}
@@ -89,7 +89,7 @@ public class PolicyEnforcementMiddlewareTests
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
var delegatedAdmin = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
@@ -605,6 +605,87 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
}
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
[Fact]
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
{
const string upn = "ga@vendor.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
foreach (var (method, path) in new[]
{
("PATCH", $"/api/config/{TenantB}/fields"),
("GET", $"/api/config/{TenantB}/backups"),
("POST", $"/api/config/{TenantB}/revert"),
})
{
var result = await h.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed, $"{method} {path} must admit a Global Admin");
}
}
[Fact]
public void ConfigFieldsSchema_LiteralRoute_WinsOverTenantIdTemplate()
{
// "fields-schema" must never be swallowed as a {tenantId} of the MemberRead config GET —
// FindPolicy's literal-over-template precedence is what this feature's route relies on
// (same mechanism as config/all).
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/fields-schema");
Assert.NotNull(entry);
Assert.Equal("config/fields-schema", entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
}
[Fact]
public async Task ConfigFieldsSchema_GlobalAdminAllowed_ReaderAndTenantAdminForbidden()
{
var ga = BuildHarness();
ga.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
Assert.True((await ga.Middleware.DecideAsync(
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "ga@vendor.example"))).Allowed);
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
Assert.False((await reader.Middleware.DecideAsync(
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "reader@vendor.example"))).Allowed);
var tenantAdmin = BuildHarness();
tenantAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
Assert.False((await tenantAdmin.Middleware.DecideAsync(
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "admin@contoso.com"))).Allowed);
}
[Fact]
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
{
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
foreach (var (method, path) in new[]
{
("PATCH", $"/api/config/{TenantA}/fields"),
("GET", $"/api/config/{TenantA}/backups"),
("POST", $"/api/config/{TenantA}/revert"),
})
{
var ownAdmin = BuildHarness();
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var readerResult = await reader.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "reader@vendor.example"));
Assert.False(readerResult.Allowed, $"{method} {path} must deny a read-only Global Reader");
var delegated = BuildHarness();
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
}
}
[Fact]
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
{
@@ -672,6 +753,8 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed); // the delegated read itself is allowed…
// …but no config row was persisted for the MSP's home tenant A (nor any tenant).
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
@@ -1,12 +1,12 @@
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the static helpers <c>PreviewWhitelistFunction.PickRequesterUpn</c>
/// and <c>PreviewWhitelistFunction.IsRealUserUpn</c>.
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
/// <para>
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
/// 10 tenants' TenantAdmins rows by writing the sentinel string
@@ -31,7 +31,7 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData("cadm_user@xx3t8.onmicrosoft.com")]
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
{
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(upn));
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
}
[Theory]
@@ -46,7 +46,7 @@ public class PreviewWhitelistRequesterUpnTests
// The shape-based prefix check is exactly the defense against the corruption
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
// is rejected without anyone needing to remember to update this method.
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
}
[Theory]
@@ -57,7 +57,7 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData("just-a-string")]
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
{
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
}
// -------------------------------------------------------------------------
@@ -73,7 +73,7 @@ public class PreviewWhitelistRequesterUpnTests
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("onboarder@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
@@ -85,7 +85,7 @@ public class PreviewWhitelistRequesterUpnTests
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
@@ -97,7 +97,7 @@ public class PreviewWhitelistRequesterUpnTests
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
@@ -112,8 +112,8 @@ public class PreviewWhitelistRequesterUpnTests
UpdatedBy = "System (Global Rate Limit Sync)",
};
Assert.Equal("real.requester@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(PreviewWhitelistFunction.PickRequesterUpn(cfg)));
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
}
[Fact]
@@ -127,8 +127,8 @@ public class PreviewWhitelistRequesterUpnTests
UpdatedBy = "System (Global Rate Limit Sync)",
};
var picked = PreviewWhitelistFunction.PickRequesterUpn(cfg);
var picked = TenantApprovalService.PickRequesterUpn(cfg);
Assert.Equal("System (Global Rate Limit Sync)", picked);
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(picked));
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
}
}
@@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
@@ -11,43 +9,24 @@ using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Gate-coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
/// call is not exercised here — the const-false arm-flag short-circuits before any client
/// construction. These tests instead guard the disarmed-by-default contract:
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
/// call is not exercised here — every test drives one of the no-op gates. Pins:
/// <list type="bullet">
/// <item>The arm constant defaults to <c>false</c>, so an accidental merge cannot ship
/// the placeholder template.</item>
/// <item><see cref="ResendEmailService.SendAsync"/> never throws under disarmed state —
/// not even when the API key is empty or the recipient is null. The handler's
/// fail-soft try/catch is a belt; this is the suspenders.</item>
/// <item>The disarmed code path logs a debug-level "feature disarmed" line so operators
/// have a positive signal in app insights that "the build is intentionally not
/// sending farewell emails", as opposed to a silent regression.</item>
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
/// is empty and not when the recipient is missing. The handler's fail-soft
/// try/catch is a belt; this is the suspenders.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class ResendEmailServiceTests
{
[Fact]
public void OffboardFarewellEmailArmed_DefaultsToFalse()
{
// Guard the disarmed-by-design contract. Flipping this constant to true is the
// explicit "scharf schalten" action the user does after finalising the template.
// If this test fails, somebody flipped the flag — make sure the template, feedback
// form, and unsubscribe story are all signed off before letting the change land.
var field = typeof(ResendEmailService).GetField(
"OffboardFarewellEmailArmed",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(field);
var armed = (bool)field!.GetRawConstantValue()!;
Assert.False(armed,
"OffboardFarewellEmailArmed must default to false until the offboarding farewell template + feedback-form story is signed off.");
}
[Fact]
public async Task SendAsync_Disarmed_DoesNotThrow_AndLogsDisarmedDebugLine()
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "any-non-empty-key", logger);
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "ops@contoso.invalid",
@@ -56,31 +35,11 @@ public sealed class ResendEmailServiceTests
ct: CancellationToken.None);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
}
[Fact]
public async Task SendAsync_Disarmed_EmptyApiKey_StillNoOps()
{
// Even with no API key configured the disarmed early-return fires FIRST — so the
// missing-key debug line must NOT show up; only the disarmed line does. This pins
// the gate ordering: disarmed > missing-key > missing-email.
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "ops@contoso.invalid",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("RESEND_API_KEY not configured"));
}
[Fact]
public async Task SendAsync_Disarmed_EmptyRecipient_StillNoOps()
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "any-non-empty-key", logger);
@@ -91,11 +50,58 @@ public sealed class ResendEmailServiceTests
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
{
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
// the key line must show up.
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
[Fact]
public void FarewellTemplate_IsFinal_NoDraftResidue()
{
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.DoesNotContain("DRAFT", html);
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
Assert.DoesNotContain("TODO", html);
}
[Fact]
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.Contains("contoso.invalid", html);
// The mail is sent from a noreply address after portal access is gone, so the
// feedback pointers must be external channels the recipient can actually reach.
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
Assert.Contains("linkedin.com", html);
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("");
Assert.Contains("your organization", html);
}
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
{
var config = new ConfigurationBuilder()
@@ -29,7 +29,7 @@ public class RevokeEnforcementTests
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
new(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
@@ -0,0 +1,139 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the merge collision guard in <see cref="AnalyzeRuleService.GetAllRulesForTenantAsync"/>
/// and <see cref="GatherRuleService.GetAllRulesForTenantAsync"/>: a tenant-partition row whose
/// RuleId collides with a merged global rule (legacy debris from the pre-global-partition
/// seeding era, e.g. tenant 5ca2b350's ANALYZE-ID-002 copy from 2026-03) must be skipped —
/// the global definition wins. Without the guard the same RuleId appears twice in the merged
/// list and every ToDictionary(r =&gt; r.RuleId) consumer throws (prod: ArgumentException in
/// AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync, killing rule notifications).
/// </summary>
public class RuleMergeCollisionGuardTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
[Fact]
public async Task Analyze_stale_tenant_copy_of_global_rule_is_skipped()
{
var collidingId = BuiltInAnalyzeRules.GetAll().First().RuleId;
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
{
StaleTenantAnalyzeCopy(collidingId),
});
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var merged = await service.GetAllRulesForTenantAsync(TenantId);
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
Assert.Single(instances);
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
// The exact downstream consumer that crashed in prod must work again.
_ = merged.ToDictionary(r => r.RuleId);
}
[Fact]
public async Task Analyze_non_colliding_custom_rule_still_merges()
{
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
{
StaleTenantAnalyzeCopy("TENANT-CUSTOM-XYZ"),
});
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var merged = await service.GetAllRulesForTenantAsync(TenantId);
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
}
[Fact]
public async Task Gather_stale_tenant_copy_of_global_rule_is_skipped()
{
var collidingId = BuiltInGatherRules.GetAll().First().RuleId;
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
{
StaleTenantGatherCopy(collidingId),
});
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var merged = await service.GetAllRulesForTenantAsync(TenantId);
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
Assert.Single(instances);
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
_ = merged.ToDictionary(r => r.RuleId);
}
[Fact]
public async Task Gather_non_colliding_custom_rule_still_merges()
{
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
{
StaleTenantGatherCopy("TENANT-CUSTOM-XYZ"),
});
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var merged = await service.GetAllRulesForTenantAsync(TenantId);
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
}
// ===== Helpers =====
private static AnalyzeRule StaleTenantAnalyzeCopy(string ruleId) => new()
{
RuleId = ruleId,
Title = "Stale tenant copy",
Description = "Legacy per-tenant seeded row",
Severity = "warning",
Category = "test",
IsBuiltIn = false,
IsCommunity = false,
Enabled = true,
};
private static GatherRule StaleTenantGatherCopy(string ruleId) => new()
{
RuleId = ruleId,
Title = "Stale tenant copy",
Description = "Legacy per-tenant seeded row",
Category = "test",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\X",
Trigger = "startup",
IsBuiltIn = false,
IsCommunity = false,
Enabled = true,
};
/// <summary>DB state = exactly the shipped catalog (clean seed diff), plus the given tenant rows.</summary>
private static Mock<IRuleRepository> CreateAnalyzeRepo(List<AnalyzeRule> tenantRules)
{
var repo = new Mock<IRuleRepository>();
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(BuiltInAnalyzeRules.GetAll().ToList());
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
return repo;
}
private static Mock<IRuleRepository> CreateGatherRepo(List<GatherRule> tenantRules)
{
var repo = new Mock<IRuleRepository>();
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(BuiltInGatherRules.GetAll().ToList());
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(tenantRules);
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.DeleteGatherRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
return repo;
}
}
@@ -483,18 +483,29 @@ public class SessionDeletionWorkerTests
};
harness.EnqueueMessage(JsonConvert.SerializeObject(envelope), dequeueCount: 1);
// Handler hangs for 350ms so the heartbeat task ticks at least 5×.
// De-flake (CI: a fixed 350ms handler window raced the 50ms heartbeat timer on loaded
// runners — the handler could finish before 3 heartbeats fired, after which the awaited
// condition can never become true). Instead the handler holds the message in-flight
// UNTIL the heartbeats have been observed: the heartbeat task ticks for as long as the
// handler runs, so the condition is reached deterministically regardless of scheduler
// jitter. A genuinely broken heartbeat leaves the loop to the worker's cancellation at
// RunUntilAsync's ceiling, and the Times.AtLeast(3) below fails as intended.
harness.HandlerMock.Setup(h => h.HandleAsync(
It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionDeletionEnvelope _, CancellationToken ct) =>
{
await Task.Delay(TimeSpan.FromMilliseconds(350), ct);
// Own deadline besides ct: if the heartbeat were broken AND shutdown never
// cancelled the handler token, the test must still terminate (and then fail
// on the verify) instead of hanging in StopAsync.
var handlerDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
while (!ct.IsCancellationRequested && harness.HeartbeatCount() < 3 && DateTime.UtcNow < handlerDeadline)
await Task.Delay(10, CancellationToken.None);
});
await harness.RunUntilAsync(() => harness.HeartbeatCount() >= 3);
// Heartbeat called at least 3× while the handler was busy (350ms / 50ms ≈ 7, minus a few
// due to scheduling jitter). One call is enough to prove the heartbeat task is wired.
// Heartbeat fired at least 3× while the handler was busy — proves the heartbeat task
// is wired and keeps extending visibility for the whole duration of a long handler run.
harness.MainQueue.Verify(q => q.UpdateMessageAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Azure;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The diagnostics-archive copier preserves a session's diag ZIP with a session report.
/// Its contract: NEVER throw (a failed copy must not block the report), re-verify the
/// hosted tenant prefix (a tampered Sessions row must not reach foreign blobs), and honour
/// the same size cap as the download proxy. Tests pin every status branch via a recording
/// subclass — no Azurite, no HTTP.
/// </summary>
public class SessionReportDiagnosticsArchiveCopierTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string HostedBlob = $"{TenantA}/AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string CustomerBlob = $"AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string DestBlob = $"{TenantA}_{SessionId}_diag_archive_20260729_120000.zip";
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task CopyAsync_NoSource_FailsSoft_NoStorageCalls(string? source)
{
var copier = new RecordingCopier();
var result = await copier.CopyAsync(TenantA, SessionId, source, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedNoDiagnostics, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_HostedBlobOfForeignTenant_Rejected_WithoutRead()
{
// Spoof guard: even if the Sessions row was tampered to point at another tenant's
// hosted blob, the classifier re-checks the prefix against the validated tenant.
var copier = new RecordingCopier();
var foreignBlob = $"{TenantB}/AgentDiagnostics-{SessionId}-x.zip";
var result = await copier.CopyAsync(TenantA, SessionId, foreignBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
{
var copier = new RecordingCopier { SourceSizeBytes = 1234 };
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.Copied, result.Status);
Assert.Equal(1234, result.SizeBytes);
Assert.Equal(1, copier.HostedOpenCalls);
Assert.Equal(HostedBlob, copier.HostedOpenLastPath);
Assert.Equal(1, copier.UploadCalls);
Assert.Equal(DestBlob, copier.UploadLastName);
Assert.DoesNotContain("/", copier.UploadLastName);
}
[Fact]
public async Task CopyAsync_CustomerSas_HappyPath_BuildsSasBlobUrl()
{
var copier = new RecordingCopier
{
SourceSizeBytes = 42,
CustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=r&sig=x",
};
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.CustomerOpenCalls);
Assert.Equal(
$"https://customer.blob/diag/{CustomerBlob}?sv=2024&sp=r&sig=x",
copier.CustomerOpenLastUri!.ToString());
Assert.Equal(1, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_CustomerSas_NoSasConfigured_FailsSoft()
{
var copier = new RecordingCopier { CustomerSasUrl = null };
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasNotConfigured, result.Status);
Assert.Equal(0, copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SourceAboveSizeCap_Rejected_NoUpload()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 1,
SourceSizeBytes = 2 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTooLarge, result.Status);
Assert.Equal(2 * 1024 * 1024, result.SizeBytes);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SizeCapZero_MeansUnlimited()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 0,
SourceSizeBytes = 5L * 1024 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.UploadCalls);
}
[Theory]
[InlineData(404, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSourceNotFound)]
[InlineData(403, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasReadDenied)]
[InlineData(500, SessionReportDiagnosticsArchiveCopier.Statuses.FailedError)]
public async Task CopyAsync_RequestFailed_MapsStatus_WithoutThrowing(int httpStatus, string expected)
{
var copier = new RecordingCopier
{
HostedOpenException = new RequestFailedException(httpStatus, $"storage error {httpStatus}"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(expected, result.Status);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Timeout_MapsToTimeoutStatus()
{
var copier = new RecordingCopier
{
HostedOpenException = new OperationCanceledException(),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTimeout, result.Status);
}
[Fact]
public async Task CopyAsync_UploadFailure_FailsSoft_AsError()
{
// Read succeeded but the write into session-reports blew up — still fail-soft.
var copier = new RecordingCopier
{
UploadException = new InvalidOperationException("container gone"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedError, result.Status);
}
/// <summary>
/// Overrides every storage/config seam with an in-memory recording version, so the
/// tests exercise the REAL CopyAsync routing (classify → config → open → cap → upload)
/// without touching Azure.
/// </summary>
private sealed class RecordingCopier : SessionReportDiagnosticsArchiveCopier
{
public int MaxDownloadSizeMB { get; set; } = 500;
public long SourceSizeBytes { get; set; } = 100;
public string? CustomerSasUrl { get; set; }
public Exception? HostedOpenException { get; set; }
public Exception? UploadException { get; set; }
public int HostedOpenCalls { get; private set; }
public string? HostedOpenLastPath { get; private set; }
public int CustomerOpenCalls { get; private set; }
public Uri? CustomerOpenLastUri { get; private set; }
public int UploadCalls { get; private set; }
public string? UploadLastName { get; private set; }
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration
{
MaxDiagnosticsDownloadSizeMB = MaxDownloadSizeMB,
DiagnosticsDownloadTimeoutSeconds = 120,
});
protected override Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
=> Task.FromResult(new TenantConfiguration
{
DiagnosticsBlobSasUrl = CustomerSasUrl!,
});
protected override Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
string blobPath, CancellationToken cancellationToken)
{
HostedOpenCalls++;
HostedOpenLastPath = blobPath;
if (HostedOpenException != null) throw HostedOpenException;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
Uri blobUri, CancellationToken cancellationToken)
{
CustomerOpenCalls++;
CustomerOpenLastUri = blobUri;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task UploadToReportsContainerAsync(
string destinationBlobName, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastName = destinationBlobName;
if (UploadException != null) throw UploadException;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
/// session-report archive. The sanitizer must strip path components so a crafted name
/// cannot produce traversal-shaped entries that an extraction tool would write outside
/// the target directory.
/// </summary>
public class SessionReportZipEntryNameTests
{
[Theory]
[InlineData("agent.log", "agent.log")]
[InlineData("agent_20260729.log", "agent_20260729.log")]
[InlineData("logs/agent.log", "agent.log")]
[InlineData("..\\..\\evil.exe", "evil.exe")]
[InlineData("../../../etc/passwd", "passwd")]
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
[InlineData(" spaced.log ", "spaced.log")]
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
{
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("..")]
[InlineData(".")]
[InlineData("logs/")]
[InlineData("a/../")]
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
{
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
}
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Plausibility guard for the session anchor (session df1fcf47): ONE do_telemetry event whose
/// OccurredUtc inherited a tz-shifted IME log line 8h in the past dragged StartedAt back ~7h
/// and inflated the WG Part 1 DurationSeconds from ~1h16m to 8h15m. The earliest-event
/// timestamp — whether from the ingest batch or the Events-table probe — may only re-anchor
/// StartedAt / the duration start when it sits within
/// <see cref="TableStorageService.MaxStartedAtBackwardShift"/> of the current anchor.
/// </summary>
public class SessionStartedAtBackwardShiftGuardTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private static readonly DateTime StartedAt = new(2026, 7, 29, 18, 45, 15, DateTimeKind.Utc);
// ==================================================== StartedAt alignment ====
[Fact]
public async Task IncrementEventCount_rejects_backward_shift_beyond_window()
{
var harness = new Harness(SessionRow());
// The df1fcf47 shape: one event 8h before the session's real start.
var skewed = StartedAt.AddHours(-7);
var result = await harness.Sut.IncrementSessionEventCountAsync(
TenantId, SessionId, increment: 1,
earliestEventTimestamp: skewed, latestEventTimestamp: StartedAt.AddMinutes(10));
Assert.NotNull(result);
Assert.NotNull(harness.Written);
Assert.False(harness.Written!.ContainsKey("StartedAt"));
}
[Fact]
public async Task IncrementEventCount_accepts_backward_shift_within_window()
{
var harness = new Harness(SessionRow());
// Legitimate pre-registration backlog: minutes, not hours.
var earlier = StartedAt.AddMinutes(-30);
var result = await harness.Sut.IncrementSessionEventCountAsync(
TenantId, SessionId, increment: 1,
earliestEventTimestamp: earlier, latestEventTimestamp: StartedAt.AddMinutes(10));
Assert.NotNull(result);
Assert.NotNull(harness.Written);
Assert.Equal(new DateTimeOffset(earlier), harness.Written!.GetDateTimeOffset("StartedAt"));
}
// ============================================= Pending (WG Part 1) duration ====
[Fact]
public async Task Pending_duration_ignores_skewed_stored_earliest_event()
{
// Exact df1fcf47 replica: the poisoned event is already IN the Events table
// (probe returns it) AND rides in the batch as earliestEventTimestamp.
var poisoned = StartedAt.AddHours(-7);
var lastEvent = new DateTime(2026, 7, 29, 20, 1, 9, DateTimeKind.Utc);
var harness = new Harness(SessionRow(), earliestStoredEvent: poisoned);
var ok = await harness.Sut.UpdateSessionStatusAsync(
TenantId, SessionId, SessionStatus.Pending,
earliestEventTimestamp: poisoned, latestEventTimestamp: lastEvent);
Assert.True(ok);
Assert.NotNull(harness.Written);
// Duration anchors on StartedAt (18:45:15 → 20:01:09 = 4554s), not on the skewed
// event (which would give 29690s less the 7h offset — the original inflation).
Assert.Equal((int)(lastEvent - StartedAt).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
Assert.False(harness.Written.ContainsKey("StartedAt"));
}
[Fact]
public async Task Pending_duration_uses_plausible_stored_earliest_event()
{
var earliest = StartedAt.AddMinutes(-5);
var lastEvent = StartedAt.AddMinutes(70);
var harness = new Harness(SessionRow(), earliestStoredEvent: earliest);
var ok = await harness.Sut.UpdateSessionStatusAsync(
TenantId, SessionId, SessionStatus.Pending,
latestEventTimestamp: lastEvent);
Assert.True(ok);
Assert.NotNull(harness.Written);
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
// ============================================================ Harness ====
private static TableEntity SessionRow()
{
var entity = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(StartedAt),
["Status"] = "InProgress",
["EventCount"] = 10,
};
entity.ETag = new ETag("0xEXISTING");
return entity;
}
/// <summary>
/// SDK-mock harness (same shape as <see cref="StoreSessionReregistrationPreserveTests"/>):
/// Sessions table configured for read + merge-capture; Events table optionally serves the
/// earliest-event probe; SessionsIndex stays unconfigured — its sync is swallow-all in the
/// SUT and irrelevant here.
/// </summary>
private sealed class Harness
{
public TableStorageService Sut { get; }
public TableEntity? Written { get; private set; }
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
{
var sessions = new Mock<TableClient>();
sessions.Setup(t => t.GetEntityAsync<TableEntity>(
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(existing, new Mock<Response>().Object));
var ifExists = new Mock<NullableResponse<TableEntity>>();
ifExists.SetupGet(r => r.HasValue).Returns(true);
ifExists.SetupGet(r => r.Value).Returns(existing);
sessions.Setup(t => t.GetEntityIfExistsAsync<TableEntity>(
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ifExists.Object);
sessions.Setup(t => t.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
Written = e;
return Task.FromResult(new Mock<Response>().Object);
});
var events = new Mock<TableClient>();
var eventRows = earliestStoredEvent.HasValue
? new[]
{
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
{
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
},
}
: Array.Empty<TableEntity>();
events.Setup(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
{
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
}));
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Events)).Returns(events.Object);
// SessionsIndex intentionally unconfigured → null client → SUT swallows.
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}
@@ -0,0 +1,156 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
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>
/// Unit tests for <see cref="TenantApprovalService.ApproveWithSideEffectsAsync"/> — the
/// idempotency contract: the conditional whitelist insert decides which caller "wins" a
/// concurrent activation, and only the winner runs the side effects (auto-promote,
/// welcome mail). The loser returns false and touches nothing.
/// </summary>
public class TenantApprovalServiceTests
{
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string ApprovedBy = "ga@contoso.com";
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<ResendEmailService> _resendMock;
private readonly TenantApprovalService _sut;
public TenantApprovalServiceTests()
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
_tenantConfigMock = new Mock<TenantConfigurationService>(
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
_previewMock = new Mock<PreviewWhitelistService>(
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_resendMock = new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
_previewMock.Object,
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_resendMock.Object);
}
[Fact]
public async Task AlreadyApproved_ReturnsFalse_AndRunsNoSideEffects()
{
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.False(newlyApproved);
// The side-effect block (auto-promote + welcome mail) starts with the config read —
// it must never run for a lost activation race.
_tenantConfigMock.Verify(x => x.GetConfigurationAsync(It.IsAny<string>()), Times.Never);
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NewlyApproved_ReturnsTrue()
{
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
// Default config has no OnboardedBy/UpdatedBy UPN → auto-promote is skipped by the
// shape guard; no notification email → no mail. Only the return value is under test.
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.True(newlyApproved);
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
}
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
// notification-email save path. Ordering contract under test: the sent-marker must be
// consumed strictly AFTER the address check (a marker consumed with no address would
// permanently suppress the mail), and a lost marker race must not send.
[Fact]
public async Task TrySendWelcomeEmail_NoAddressYet_DefersWithoutConsumingMarker()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TrySendWelcomeEmail_AddressPresent_MarkerWon_SendsOnce()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
var config = TenantConfiguration.CreateDefault(TenantId);
config.DomainName = "contoso.com";
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.True(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
}
[Fact]
public async Task TrySendWelcomeEmail_MarkerAlreadyConsumed_DoesNotSend()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(false);
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TrySendWelcomeEmail_MarkerStorageError_ReturnsFalse_WithoutSending()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId))
.ThrowsAsync(new InvalidOperationException("storage down"));
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ApproveWithSideEffects_AddressAlreadySaved_SendsWelcomeMail()
{
// The user saved their notification email BEFORE activation (manual-approve shape):
// the approval path itself must send.
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.True(newlyApproved);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
}
}
@@ -0,0 +1,193 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Activation;
using AutopilotMonitor.Functions.Services.Notifications;
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>
/// Unit tests for <see cref="TenantAutoApproveHandler"/> — the consumer logic of the
/// <c>tenant-auto-approve</c> queue. Every early return must DROP (never approve);
/// only the happy path may call the shared activation service, and it must use the
/// "System (auto-approve)" sentinel that the UPN shape-guard rejects as a promotable user.
/// </summary>
public class TenantAutoApproveHandlerTests
{
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string Upn = "first.user@contoso.com";
private readonly Mock<AdminConfigurationService> _adminConfigMock;
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<TenantApprovalService> _approvalMock;
private readonly List<OpsEventEntry> _savedOpsEvents = new();
private readonly TenantAutoApproveHandler _sut;
public TenantAutoApproveHandlerTests()
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
_adminConfigMock = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
_tenantConfigMock = new Mock<TenantConfigurationService>(
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
_previewMock = new Mock<PreviewWhitelistService>(
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_approvalMock = new Mock<TenantApprovalService>(
NullLogger<TenantApprovalService>.Instance,
_previewMock.Object,
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance).Object)
{ CallBase = false };
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_sut = new TenantAutoApproveHandler(
NullLogger<TenantAutoApproveHandler>.Instance,
_adminConfigMock.Object,
_previewMock.Object,
_tenantConfigMock.Object,
_approvalMock.Object,
opsService);
// Defaults: flag on, tenant not yet approved, config exists and is enabled. The
// handler reads the config via the cache-bypassing GetConfigurationFreshAsync — the
// suspension gate must never decide on a stale per-instance cache.
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(true);
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(false);
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
}
private static TenantAutoApproveEnvelope Envelope() => new()
{
TenantId = TenantId,
SignupUpn = Upn,
EnqueuedAtUtc = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task FlagOff_DropsWithoutApproving()
{
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(false);
await _sut.HandleAsync(Envelope(), CancellationToken.None);
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task AlreadyApproved_Drops()
{
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(true);
await _sut.HandleAsync(Envelope(), CancellationToken.None);
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TenantConfigMissing_Drops()
{
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync((TenantConfiguration?)null);
await _sut.HandleAsync(Envelope(), CancellationToken.None);
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SuspendedTenant_Drops()
{
var suspended = TenantConfiguration.CreateDefault(TenantId);
suspended.Disabled = true;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync(suspended);
await _sut.HandleAsync(Envelope(), CancellationToken.None);
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ConfigReadError_Propagates_SoTheMessageRetries()
{
// A storage blip must NOT silently drop the message (that would permanently
// downgrade the tenant to manual approval) — it throws, the queue retries, and
// the flag + suspension gates re-run on every attempt.
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ThrowsAsync(new InvalidOperationException("storage blip"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.HandleAsync(Envelope(), CancellationToken.None));
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task HappyPath_ApprovesWithSentinel_AndEmitsOpsEvent()
{
await _sut.HandleAsync(Envelope(), CancellationToken.None);
_approvalMock.Verify(
x => x.ApproveWithSideEffectsAsync(TenantId, TenantAutoApproveHandler.AutoApprovedBy),
Times.Once);
var evt = Assert.Single(_savedOpsEvents);
Assert.Equal("TenantAutoApproved", evt.EventType);
Assert.Equal(TenantId, evt.TenantId);
}
[Fact]
public async Task ActivationRaceLost_DropsWithoutOpsEvent()
{
// Double signup enqueues two envelopes; the conditional whitelist insert lets
// exactly one worker win. The loser must not emit a second TenantAutoApproved
// ops event (the winner's side effects already ran).
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
await _sut.HandleAsync(Envelope(), CancellationToken.None);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public void AutoApprovedBySentinel_IsRejectedByUpnShapeGuard()
{
// The sentinel must never pass the promotable-user shape check — that is the
// defense against writing a system string into the TenantAdmins table.
Assert.False(TenantApprovalService.IsRealUserUpn(TenantAutoApproveHandler.AutoApprovedBy));
}
}
@@ -0,0 +1,623 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
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 Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Transactional config patch/revert: field gates, fail-closed backup, CAS retry,
/// exactly-these-fields verification with automatic rollback, and the phase-2
/// caller-tier deny-lists. The harness emulates real table CAS semantics (versioned
/// ETags, conditional replace) so the retry/rollback paths are exercised honestly.
/// </summary>
public class TenantConfigPatchServiceTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Ga = "ga@operator.example";
/// <summary>In-memory row with real CAS semantics + optional write-time corruption hook.</summary>
private sealed class Harness
{
public Mock<IConfigRepository> Repo { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public TenantConfigPatchService Sut { get; }
public TenantConfiguration? Current;
public int Version = 1;
public List<ConfigBackupEntry> Backups { get; } = new();
public int ReplaceCalls;
/// <summary>Applied to the stored row on every successful replace — simulates serialization drift.</summary>
public Func<TenantConfiguration, TenantConfiguration>? CorruptOnWrite;
/// <summary>Bumps the version WITHOUT a replace before the Nth replace call — simulates a concurrent writer.</summary>
public int? StealEtagBeforeReplaceCall;
public Harness(TenantConfiguration? stored)
{
Current = stored;
Repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
.ReturnsAsync(() => Current == null
? ((TenantConfiguration, string)?)null
: (Clone(Current), $"etag-{Version}"));
Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
.Returns<TenantConfiguration, string>((config, etag) =>
{
ReplaceCalls++;
if (StealEtagBeforeReplaceCall == ReplaceCalls)
{
Version++; // the concurrent writer got there first
StealEtagBeforeReplaceCall = null;
}
if (etag != $"etag-{Version}")
return Task.FromResult(false);
Current = CorruptOnWrite != null ? CorruptOnWrite(Clone(config)) : Clone(config);
Version++;
return Task.FromResult(true);
});
Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => Backups.Add(e))
.Returns(Task.CompletedTask);
Backup.Setup(b => b.ListByPartitionAsync(TenantId, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => Backups.AsEnumerable().Reverse().ToList()); // newest first
Backup.Setup(b => b.TryGetAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns<string, string, CancellationToken>((_, id, _) =>
Task.FromResult(Backups.FirstOrDefault(b => b.RowKey == id)));
Maintenance.Setup(m => m.LogAuditEntryAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var configService = new TenantConfigurationService(
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
Sut = new TenantConfigPatchService(
Repo.Object, Backup.Object, configService, Maintenance.Object,
NullLogger<TenantConfigPatchService>.Instance);
}
private static TenantConfiguration Clone(TenantConfiguration c)
=> JsonConvert.DeserializeObject<TenantConfiguration>(JsonConvert.SerializeObject(c))!;
}
private static TenantConfiguration Stored()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.DomainName = "contoso.com";
config.UpdatedBy = "admin@contoso.com";
config.DataRetentionDays = 30;
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
return config;
}
private static JObject Fields(params (string Key, object? Value)[] fields)
{
var o = new JObject();
foreach (var (key, value) in fields)
o[key] = value == null ? JValue.CreateNull() : JToken.FromObject(value);
return o;
}
// ── Happy path ──────────────────────────────────────────────────────────
[Fact]
public async Task Patch_SingleField_Applies_BacksUp_Verifies_Audits()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "retention bump");
Assert.True(outcome.Success);
Assert.Equal(new[] { "DataRetentionDays" }, outcome.AppliedFields);
Assert.Equal(90, harness.Current!.DataRetentionDays);
Assert.Equal(Ga, harness.Current.UpdatedBy);
Assert.NotNull(outcome.BackupId);
var backup = Assert.Single(harness.Backups);
Assert.Equal("mcp-patch", backup.Source);
Assert.Equal("retention bump", backup.Reason);
Assert.Contains("\"DataRetentionDays\":30", backup.EntityJson); // snapshot = PRE-write state
Assert.True(outcome.MaskedDiff!.ContainsKey("DataRetentionDays"));
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
TenantId, "PATCH", "TenantConfiguration", TenantId, Ga,
It.Is<Dictionary<string, string>?>(d => d != null && d["BackupId"] == outcome.BackupId)), Times.Once);
}
[Fact]
public async Task Patch_NullValue_ClearsNullableField()
{
var stored = Stored();
stored.ContactEmail = "ops@contoso.com";
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("contactEmail", null)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Null(harness.Current!.ContactEmail);
}
[Fact]
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 30)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Empty(outcome.AppliedFields);
Assert.Null(outcome.BackupId);
Assert.Empty(harness.Backups);
Assert.Equal(0, harness.ReplaceCalls);
}
// ── Field gate ──────────────────────────────────────────────────────────
[Theory]
[InlineData("planTier")]
[InlineData("trialExpiresUtc")]
[InlineData("homedAppClientId")]
[InlineData("lastAuthClientId")]
[InlineData("onboardedBy")]
[InlineData("tenantId")]
[InlineData("domainName")]
[InlineData("lastUpdated")]
[InlineData("updatedBy")]
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields((field, "x")), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
Assert.Contains(field, outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.Empty(harness.Backups);
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_UnknownField_Returns400WithFieldName()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("definitelyNotAField", 1)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
Assert.Contains("definitelyNotAField", outcome.Error);
}
[Fact]
public async Task Patch_RedactedPlaceholderValue_Rejected()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("webhookUrl", Constants.RedactedSecretPlaceholder)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
Assert.Contains("redacted", outcome.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Patch_GaOnlyField_AllowedForGa_DeniedForTenantAdminTier()
{
var gaHarness = new Harness(Stored());
var gaOutcome = await gaHarness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("bootstrapTokenEnabled", true)), Ga, "mcp-patch", null,
TenantConfigCallerTier.GlobalAdmin);
Assert.True(gaOutcome.Success);
foreach (var tier in new[] { TenantConfigCallerTier.TenantAdmin, TenantConfigCallerTier.DelegatedAdmin })
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("bootstrapTokenEnabled", true)), "admin@contoso.com", "api-patch", null, tier);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
}
}
// ── Validation ──────────────────────────────────────────────────────────
[Fact]
public async Task Patch_InvalidWebhookUrl_ValidationFails_NoWrite()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("webhookUrl", "http://169.254.169.254/latest")), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Equal(0, harness.ReplaceCalls);
Assert.Empty(harness.Backups);
}
[Fact]
public async Task Patch_BadNotificationChannelsJson_ValidationFails()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("notificationChannelsJson", "{not json")), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
}
[Fact]
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
{
var harness = new Harness(Stored()); // gate (UnrestrictedModeEnabled) is false by default
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("unrestrictedMode", true)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("UnrestrictedModeEnabled", outcome.Error);
Assert.Equal(0, harness.ReplaceCalls);
}
// ── Storage edges ───────────────────────────────────────────────────────
[Fact]
public async Task Patch_NoConfigRow_NotFound()
{
var harness = new Harness(stored: null);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
}
[Fact]
public async Task Patch_BackupFailure_IsFailClosed_NoWrite()
{
var harness = new Harness(Stored());
harness.Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("backup table down"));
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.BackupFailed, outcome.Failure);
Assert.Equal(0, harness.ReplaceCalls);
Assert.Equal(30, harness.Current!.DataRetentionDays); // untouched
}
[Fact]
public async Task Patch_CasRace_RetriesAndSucceeds()
{
var harness = new Harness(Stored());
harness.StealEtagBeforeReplaceCall = 1; // first replace loses, retry wins
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Equal(2, harness.ReplaceCalls);
Assert.Equal(90, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Patch_CasRaceEveryAttempt_ReturnsConflict()
{
var harness = new Harness(Stored());
harness.Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
.ReturnsAsync(false); // permanent race
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.WriteConflict, outcome.Failure);
}
// ── Verify + rollback ───────────────────────────────────────────────────
[Fact]
public async Task Patch_DriftOnWrite_RollsBackToInitial_AndReportsDriftNames()
{
var harness = new Harness(Stored());
// Simulated serialization drift: every write silently zeroes SessionTimeoutHours.
// The verify must catch the unexpected field and roll the row back. (The rollback
// write itself is also corrupted by the hook, but rollback success is judged by the
// CAS result — clear the hook after the first corruption so the rollback restores.)
harness.CorruptOnWrite = written =>
{
harness.CorruptOnWrite = null;
written.SessionTimeoutHours = 0;
return written;
};
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.DriftRolledBack, outcome.Failure);
Assert.Contains(outcome.Drift!, d => d.Contains("SessionTimeoutHours"));
Assert.NotNull(outcome.BackupId); // pre-write snapshot exists for manual inspection
// The row is back at the initial state: retention 30, timeout restored.
Assert.Equal(30, harness.Current!.DataRetentionDays);
Assert.Equal(Stored().SessionTimeoutHours, harness.Current.SessionTimeoutHours);
// No success audit for a rolled-back write.
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
It.IsAny<string>(), "PATCH", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<Dictionary<string, string>?>()), Times.Never);
}
[Fact]
public async Task Patch_DriftAndRollbackLosesRace_ReportsManualRestoreWithBackupId()
{
var harness = new Harness(Stored());
harness.CorruptOnWrite = written => { written.SessionTimeoutHours = 0; return written; };
// Every write corrupts — including the rollback attempt — but the decisive part:
// steal the ETag before the rollback replace so the rollback itself loses the race.
harness.StealEtagBeforeReplaceCall = 2;
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.DriftRollbackFailed, outcome.Failure);
Assert.Contains(outcome.BackupId!, outcome.Error);
}
// ── Revert ──────────────────────────────────────────────────────────────
private async Task<(Harness Harness, string BackupId)> PatchedHarnessAsync()
{
var harness = new Harness(Stored());
var patch = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "bump");
Assert.True(patch.Success);
return (harness, patch.BackupId!);
}
[Fact]
public async Task Revert_Latest_RestoresPatchedField_AndBacksUpCurrentFirst()
{
var (harness, _) = await PatchedHarnessAsync();
Assert.Equal(90, harness.Current!.DataRetentionDays);
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.True(outcome.Success);
Assert.Equal(30, harness.Current!.DataRetentionDays); // back to the pre-patch value
Assert.Equal(2, harness.Backups.Count); // revert snapshotted the 90-state first
Assert.Contains("\"DataRetentionDays\":90", harness.Backups[1].EntityJson);
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
TenantId, "REVERT", "TenantConfiguration", TenantId, Ga,
It.IsAny<Dictionary<string, string>?>()), Times.Once);
}
[Fact]
public async Task Revert_ByBackupId_UsesThatSnapshot()
{
var (harness, backupId) = await PatchedHarnessAsync();
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.True(outcome.Success);
Assert.Equal(30, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Revert_PreservesProtectedFields_ByDefault()
{
var (harness, backupId) = await PatchedHarnessAsync();
// Plan + homing changed AFTER the snapshot (via their dedicated flows).
harness.Current!.PlanTier = "enterprise";
harness.Current.HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.True(outcome.Success);
Assert.Equal(30, harness.Current!.DataRetentionDays); // reverted
Assert.Equal("enterprise", harness.Current.PlanTier); // protected — kept current
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", harness.Current.HomedAppClientId);
}
[Fact]
public async Task Revert_IncludeProtectedFields_RestoresThemToo()
{
var (harness, backupId) = await PatchedHarnessAsync();
harness.Current!.PlanTier = "enterprise";
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId, includeProtectedFields: true, Ga, "mcp-revert", null);
Assert.True(outcome.Success);
Assert.Equal(Stored().PlanTier, harness.Current!.PlanTier); // snapshot value restored
}
[Fact]
public async Task Revert_NoBackups_NotFound()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
}
[Fact]
public async Task Revert_UnknownBackupId_NotFound()
{
var (harness, _) = await PatchedHarnessAsync();
var outcome = await harness.Sut.RevertAsync(
TenantId, "does-not-exist", includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
}
// ── Rehydration roundtrip (serialization-drift canary) ─────────────────
[Fact]
public void RehydrateEntity_Roundtrip_EveryPropertyNonNull_ProducesIdenticalModel()
{
// Reflection filler: EVERY writable property gets a non-default value so no column
// escapes the fidelity check. Whole-number decimals (95m) are the deliberate trap —
// JSON serializes 95.0 as "95", and prod finding 2026-08-03 was exactly a whole-valued
// SLA rate column rehydrating as Int32 and blowing up TableEntity.GetDouble.
var original = TenantConfiguration.CreateDefault(TenantId);
foreach (var prop in typeof(TenantConfiguration).GetProperties()
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0))
{
var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
object? value =
t == typeof(string) ? (prop.Name is "PartitionKey" or "RowKey" or "TenantId"
? prop.GetValue(original) : $"x-{prop.Name}")
: t == typeof(bool) ? true
: t == typeof(int) ? 7
: t == typeof(decimal) ? 95m // whole number on purpose — the Int32/Double trap
: t == typeof(double) ? 95d
: t == typeof(DateTime) ? new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc)
: prop.GetValue(original);
prop.SetValue(original, value);
}
// Canonical baseline = one Store/Map roundtrip (isolates REHYDRATION fidelity from any
// pre-existing converter quirks, which TenantConfigTableSerializationTests own).
var baseline = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(original));
var snapshot = TableConfigRepository.BuildBackupEntry(
TableConfigRepository.ConvertToTenantTableEntity(baseline),
TenantId, Ga, "test", null, null);
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer
.GetChangedPropertyNames(baseline, rehydrated);
Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff));
}
[Fact]
public void RehydrateEntity_WholeNumberDecimalColumn_SurvivesAsDouble()
{
// Focused pin on the prod failure: SlaTargetSuccessRate 95 (whole) must come back as
// a readable double column, not an Int32 that makes GetDouble throw.
var original = Stored();
original.SlaTargetSuccessRate = 95m;
var snapshot = TableConfigRepository.BuildBackupEntry(
TableConfigRepository.ConvertToTenantTableEntity(original),
TenantId, Ga, "test", null, null);
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
Assert.Equal(95m, rehydrated.SlaTargetSuccessRate);
}
// ── Fields schema (MCP steering surface) ────────────────────────────────
[Fact]
public void FieldsSchema_CoversEveryModelProperty_ExactlyOnce()
{
// Drift canary: the schema is reflection-generated, so this pins that EVERY writable
// model property surfaces exactly once under its camelCase name — a new config field
// is automatically schema-visible, and a rename cannot leave a stale entry.
var schema = TenantConfigPatchService.BuildFieldsSchema();
var expected = typeof(TenantConfiguration).GetProperties()
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
.Select(p => char.ToLowerInvariant(p.Name[0]) + p.Name.Substring(1))
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
Assert.Equal(expected, schema.Select(f => f.Name).ToList());
Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
}
[Fact]
public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
{
var schema = TenantConfigPatchService.BuildFieldsSchema()
.ToDictionary(f => f.Name, StringComparer.OrdinalIgnoreCase);
foreach (var denied in TenantConfigPatchService.BaseDeniedFields
.Where(n => n is not ("PartitionKey" or "RowKey" or "Timestamp" or "ETag")))
{
Assert.False(schema[denied].Writable, $"{denied} must be marked non-writable");
Assert.False(string.IsNullOrWhiteSpace(schema[denied].Reason), $"{denied} needs a deny reason");
}
foreach (var gaOnly in TenantConfigPatchService.GaOnlyFields)
{
Assert.True(schema[gaOnly].Writable, $"{gaOnly} is writable for GA");
Assert.True(schema[gaOnly].GaOnly, $"{gaOnly} must carry the GA-only marker");
}
foreach (var protectedField in TenantConfigPatchService.RevertProtectedFields)
{
Assert.True(schema[protectedField].RevertProtected, $"{protectedField} must carry the revert-protected marker");
}
}
[Fact]
public void FieldsSchema_TypeExemplars_AreCorrect()
{
var schema = TenantConfigPatchService.BuildFieldsSchema()
.ToDictionary(f => f.Name, StringComparer.Ordinal);
Assert.Equal(("integer", false, true), (schema["dataRetentionDays"].Type, schema["dataRetentionDays"].Nullable, schema["dataRetentionDays"].Writable));
// The prod-finding column class: decimal? → JSON "number", nullable.
Assert.Equal(("number", true), (schema["slaTargetSuccessRate"].Type, schema["slaTargetSuccessRate"].Nullable));
Assert.Equal(("boolean", true), (schema["disabled"].Type, schema["disabled"].GaOnly));
Assert.Equal(("string", true), (schema["contactEmail"].Type, schema["contactEmail"].Nullable));
Assert.Equal("json", schema["notificationChannelsJson"].Format);
Assert.Equal("date-time", schema["trialExpiresUtc"].Format);
Assert.False(schema["planTier"].Writable);
Assert.Contains("plan/trial", schema["planTier"].Reason);
Assert.False(schema["lastUpdated"].Writable);
Assert.False(schema["homedAppClientId"].Writable);
Assert.True(schema["homedAppClientId"].RevertProtected);
}
[Fact]
public void RehydrateEntity_DateLookingStringField_StaysString()
{
var original = Stored();
original.DisabledReason = "2026-08-03T12:00:00+00:00"; // adversarial: a string that parses as a date
var snapshot = TableConfigRepository.BuildBackupEntry(
TableConfigRepository.ConvertToTenantTableEntity(original),
TenantId, Ga, "test", null, null);
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
Assert.Equal(original.DisabledReason, rehydrated.DisabledReason);
}
}
@@ -30,6 +30,7 @@ public class TenantConfigProjectionTests
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed",
"disabled", "disabledReason",
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
};
[Fact]
@@ -146,4 +146,40 @@ public class TenantConfigTableSerializationTests
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).NotificationChannelsJson);
}
[Fact]
public void Roundtrip_AppRegHomingFields_SurviveStoreAndMap()
{
var since = new DateTime(2026, 7, 31, 9, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
LastAuthClientIdSince = since
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.HomedAppClientId);
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.LastAuthClientId);
Assert.Equal(since, mapped.LastAuthClientIdSince);
}
[Fact]
public void Map_LegacyRow_WithoutAppRegHomingColumns_ReadsNull()
{
// Every tenant row pre-dating the C4A8 app-reg move lacks these columns; null
// HomedAppClientId is the invariant for "homed on the legacy app registration".
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
Assert.Null(mapped.HomedAppClientId);
Assert.Null(mapped.LastAuthClientId);
Assert.Null(mapped.LastAuthClientIdSince);
}
}
@@ -109,7 +109,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
public async Task SaveConfigurationAsync_RepoReportsFalse_Throws()
{
var (service, repo) = Build();
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(false);
await Assert.ThrowsAsync<InvalidOperationException>(
@@ -125,7 +125,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
var (service, repo) = Build();
var stored = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", UpdatedBy = "x" };
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(stored);
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(false);
var cached = await service.GetConfigurationIfExistsAsync(TenantId);
@@ -144,7 +144,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
var (service, repo) = Build();
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(true);
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
@@ -188,6 +188,8 @@ public class TenantContactEmailTests
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// ------------------------------------------------------------------
@@ -285,6 +287,7 @@ public class TenantContactEmailTests
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
Mock.Of<IConfigBackupRepository>(),
NullLogger<TableConfigRepository>.Instance);
}
}
@@ -34,7 +34,7 @@ public class TenantEntitlementServiceTests
public async Task GetEdition_EnterpriseTier_ReturnsEnterprise()
{
var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
}
[Fact]
@@ -46,7 +46,7 @@ public class TenantEntitlementServiceTests
PlanTier = "free",
TrialExpiresUtc = Now.AddDays(3)
});
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
}
[Fact]
@@ -87,6 +87,8 @@ public class TenantEntitlementServiceTests
var (svc, repo) = Build(config: null);
await svc.GetEditionAsync(TenantId);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -111,15 +113,78 @@ public class TenantEntitlementServiceTests
[InlineData("free", 60, 60)] // below the cap — unchanged
[InlineData("free", 91, 90)] // above the Community cap — clamped
[InlineData("free", 180, 90)] // legacy stored value above cap — clamped
[InlineData("enterprise", 180, 180)]
[InlineData("enterprise", 365, 365)]
[InlineData("enterprise", 400, 365)] // above the Enterprise cap — clamped
[InlineData("pro", 180, 180)]
[InlineData("pro", 365, 365)]
[InlineData("pro", 400, 365)] // above the Pro cap — clamped
[InlineData("enterprise", 180, 180)] // legacy stored value — Pro cap applies
[InlineData("enterprise", 400, 365)]
public void GetEffectiveRetentionDays_ClampsToEditionCap(string tier, int stored, int expected)
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, DataRetentionDays = stored };
Assert.Equal(expected, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
}
// ── IsBootstrapEnabled / IsUnrestrictedModeActive (effective feature gates) ──
[Theory]
[InlineData("pro", false, true)] // included in the plan — no GA flag needed
[InlineData("enterprise", false, true)] // legacy stored value counts as Pro
[InlineData("free", true, true)] // Community + GA flag (additive escape hatch)
[InlineData("free", false, false)] // Community without flag → off
public void IsBootstrapEnabled_PlanOrFlag(string tier, bool gaFlag, bool expected)
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, BootstrapTokenEnabled = gaFlag };
Assert.Equal(expected, TenantEntitlementService.IsBootstrapEnabled(config, Now));
}
[Fact]
public void IsBootstrapEnabled_ActiveTrial_CountsAsPro_AndExpiryTurnsItOff()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "free",
BootstrapTokenEnabled = false,
TrialExpiresUtc = Now.AddDays(1)
};
Assert.True(TenantEntitlementService.IsBootstrapEnabled(config, Now));
Assert.False(TenantEntitlementService.IsBootstrapEnabled(config, Now.AddDays(2)));
}
[Theory]
[InlineData("pro", true, true, true)] // all three conditions met
[InlineData("pro", true, false, false)] // tenant admin has not opted in
[InlineData("pro", false, true, false)] // GA on-request gate missing
[InlineData("free", true, true, false)] // edition re-gate: Community never unrestricted
public void IsUnrestrictedModeActive_RequiresProAndGateAndToggle(
string tier, bool gate, bool toggle, bool expected)
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = tier,
UnrestrictedModeEnabled = gate,
UnrestrictedMode = toggle
};
Assert.Equal(expected, TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
}
[Fact]
public void IsUnrestrictedModeActive_TrialExpiry_RearmsGuardrails()
{
// A trial tenant may have Unrestricted Mode granted+enabled; expiry must fail closed.
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "free",
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
TrialExpiresUtc = Now.AddDays(1)
};
Assert.True(TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
Assert.False(TenantEntitlementService.IsUnrestrictedModeActive(config, Now.AddDays(2)));
}
[Fact]
public void GetEffectiveRetentionDays_ZeroInfinite_IsNeverClamped()
{
@@ -641,8 +641,8 @@ public sealed class TenantOffboardFunctionTests
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
TenantConfiguration? saved = null;
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
.Callback<TenantConfiguration>(c => saved = c)
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
.ReturnsAsync(true);
// Seed cache with stale "Disabled=false" entry so we can prove invalidation.
@@ -671,7 +671,7 @@ public sealed class TenantOffboardFunctionTests
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
// Idempotent: no Save thrash on resume-clicks while the marker is already in flight.
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -694,7 +694,7 @@ public sealed class TenantOffboardFunctionTests
var (sut, configRepo, _, _) = BuildWithConfigService();
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ThrowsAsync(new InvalidOperationException("storage save failed"));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -713,8 +713,8 @@ public sealed class TenantOffboardFunctionTests
.ReturnsAsync((TenantConfiguration?)null);
TenantConfiguration? saved = null;
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
.Callback<TenantConfiguration>(c => saved = c)
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
.ReturnsAsync(true);
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
@@ -736,7 +736,7 @@ public sealed class TenantOffboardFunctionTests
var (sut, configRepo, _, _) = BuildWithConfigService();
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(false); // transient storage rejection — NOT a throw.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -752,7 +752,7 @@ public sealed class TenantOffboardFunctionTests
var (sut, configRepo, _, _) = BuildWithConfigService();
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync((TenantConfiguration?)null);
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(false);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -771,7 +771,7 @@ public sealed class TenantOffboardFunctionTests
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -781,7 +781,7 @@ public sealed class TenantOffboardFunctionTests
var existing = TenantConfiguration.CreateDefault(TenantId);
existing.Disabled = false;
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(true);
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
@@ -832,7 +832,7 @@ public sealed class TenantOffboardFunctionTests
notYetDisabled.Disabled = false;
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync(notYetDisabled);
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(true);
var cache = new MemoryCache(new MemoryCacheOptions());
@@ -1044,6 +1044,6 @@ public sealed class TenantOffboardFunctionTests
Assert.Equal(originalEarliestProcessingAt, historyAfter.EarliestProcessingAt);
// No Save call (the tombstone was already correct).
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
}
@@ -0,0 +1,108 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Autopilot Monitor OOBE Bootstrap
Generated: 2026-07-28T12:00:00.000Z
Expires: 2026-08-01T12:00:00.000Z
This script was generated by the Autopilot Monitor platform.
It installs the monitoring agent before Intune enrollment begins,
enabling full session capture from the start of OOBE.
#>
$ErrorActionPreference = "Stop"
$AgentDownloadUrl = 'https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
# Create directories
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
# Check if agent is already installed
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath -- skipping download"
}
else {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Write-Log "Downloading agent from $AgentDownloadUrl..."
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
$retryDelays = @(2, 4, 8)
$delay = $retryDelays[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Extract
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Extracted agent to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
}
# Install agent with bootstrap token
Write-Log "Installing agent with bootstrap token..."
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "===== OOBE Bootstrap Complete ====="
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
Write-Log ""
Write-Log "The agent will:"
Write-Log " - Monitor enrollment from the very start"
Write-Log " - Switch to certificate auth after Intune enrollment"
Write-Log " - Self-destruct when enrollment completes"
Write-Host ""
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
Write-Host ""
}
catch {
Write-Log "===== OOBE Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
Write-Host ""
exit 1
}
@@ -53,6 +53,10 @@ namespace AutopilotMonitor.Functions.DataAccess
// surfaced via the Global Admin /admin/customs-archive page.
services.AddSingleton<ITenantCustomsArchiveRepository, TableTenantCustomsArchiveRepository>();
// Pre-write snapshots of TenantConfiguration/AdminConfiguration rows;
// constructor dependency of TableConfigRepository's save hook.
services.AddSingleton<IConfigBackupRepository, TableConfigBackupRepository>();
// Graph add-on permission feature: per-tenant cache of Intune script display names.
services.AddSingleton<IScriptNameCacheRepository, TableScriptNameCacheRepository>();
return services;
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table-storage backend for <see cref="IConfigBackupRepository"/>.
/// Fail-loud — callers decide whether a snapshot failure is soft (legacy save hook)
/// or hard (transactional patch/revert, which must not write without a backup).
/// </summary>
public sealed class TableConfigBackupRepository : IConfigBackupRepository
{
private readonly TableClient _tableClient;
private readonly ILogger<TableConfigBackupRepository> _logger;
public TableConfigBackupRepository(
TableStorageService storage,
ILogger<TableConfigBackupRepository> logger)
{
_tableClient = storage.GetTableClient(Constants.TableNames.ConfigurationBackups);
_logger = logger;
}
public Task UpsertAsync(ConfigBackupEntry entry, CancellationToken ct = default)
{
if (entry == null) throw new ArgumentNullException(nameof(entry));
if (string.IsNullOrEmpty(entry.PartitionKey)) throw new ArgumentException("PartitionKey required", nameof(entry));
if (string.IsNullOrEmpty(entry.RowKey)) throw new ArgumentException("RowKey required", nameof(entry));
return _tableClient.UpsertEntityAsync(Store(entry), TableUpdateMode.Replace, ct);
}
public async Task<List<ConfigBackupEntry>> ListByPartitionAsync(
string partitionKey, int max = 25, CancellationToken ct = default)
{
if (max < 1) throw new ArgumentOutOfRangeException(nameof(max));
var results = new List<ConfigBackupEntry>();
var filter = $"PartitionKey eq '{Escape(partitionKey)}'";
await foreach (var e in _tableClient.QueryAsync<TableEntity>(filter, cancellationToken: ct))
{
// Reverse-ticks RowKey → Azure's RowKey-ascending order IS newest-first.
results.Add(Map(e));
if (results.Count >= max) break;
}
return results;
}
public async Task<ConfigBackupEntry?> TryGetAsync(
string partitionKey, string backupId, CancellationToken ct = default)
{
try
{
var response = await _tableClient.GetEntityAsync<TableEntity>(partitionKey, backupId, cancellationToken: ct);
return Map(response.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task<int> PruneAsync(string partitionKey, int keep, CancellationToken ct = default)
{
if (keep < 1) throw new ArgumentOutOfRangeException(nameof(keep));
var filter = $"PartitionKey eq '{Escape(partitionKey)}'";
var seen = 0;
var deleted = 0;
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(
filter, select: new[] { "PartitionKey", "RowKey" }, cancellationToken: ct))
{
seen++;
if (seen <= keep) continue;
try
{
await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, ETag.All, ct);
deleted++;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Concurrent prune already removed it — idempotent.
}
}
return deleted;
}
/// <summary>
/// Reverse-ticks + short guid: newest sorts first, concurrent writers within the
/// same tick cannot collide. The value is the public backupId.
/// </summary>
public static string BuildRowKey(DateTime utcNow)
=> $"{DateTime.MaxValue.Ticks - utcNow.Ticks:D19}_{Guid.NewGuid():N}"[..28];
private static string Escape(string s) => s.Replace("'", "''");
private static TableEntity Store(ConfigBackupEntry e) => new(e.PartitionKey, e.RowKey)
{
["TenantId"] = e.TenantId,
["EntityJson"] = e.EntityJson,
["ChangedBy"] = e.ChangedBy,
["Source"] = e.Source,
["Reason"] = e.Reason,
["DiffJson"] = e.DiffJson,
["BackupTakenAt"] = e.BackupTakenAt,
};
private static ConfigBackupEntry Map(TableEntity e) => new()
{
PartitionKey = e.PartitionKey,
RowKey = e.RowKey,
TenantId = e.GetString("TenantId") ?? string.Empty,
EntityJson = e.GetString("EntityJson") ?? string.Empty,
ChangedBy = e.GetString("ChangedBy") ?? "system",
Source = e.GetString("Source") ?? "unknown",
Reason = e.GetString("Reason"),
DiffJson = e.GetString("DiffJson"),
BackupTakenAt = e.GetDateTime("BackupTakenAt") ?? default,
};
}
}
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -25,11 +27,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
private readonly TableClient _adminConfigTableClient;
private readonly TableClient _previewWhitelistTableClient;
private readonly TableClient _previewConfigTableClient;
private readonly IConfigBackupRepository _backupRepo;
private readonly ILogger<TableConfigRepository> _logger;
public TableConfigRepository(TableStorageService storage, ILogger<TableConfigRepository> logger)
/// <summary>
/// Changes to ONLY these properties do not snapshot the tenant-config row: the
/// LastAuthClientId pair flips as an auth-flow side effect and would flood the
/// two backup slots with states nobody ever wants to revert to.
/// </summary>
private static readonly HashSet<string> TenantBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy", "LastAuthClientId", "LastAuthClientIdSince",
};
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy",
};
public TableConfigRepository(
TableStorageService storage,
IConfigBackupRepository backupRepo,
ILogger<TableConfigRepository> logger)
{
_logger = logger;
_backupRepo = backupRepo;
_tenantConfigTableClient = storage.GetTableClient(Constants.TableNames.TenantConfiguration);
_adminConfigTableClient = storage.GetTableClient(Constants.TableNames.AdminConfiguration);
_previewWhitelistTableClient = storage.GetTableClient(Constants.TableNames.PreviewWhitelist);
@@ -56,8 +78,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
public Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
=> SaveTenantConfigurationAsync(config, backupSource: null, backupReason: null);
public async Task<bool> SaveTenantConfigurationAsync(
TenantConfiguration config, string? backupSource, string? backupReason)
{
await TrySnapshotBeforeSaveAsync(
_tenantConfigTableClient, config.TenantId, "config",
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
config.UpdatedBy, backupSource, backupReason);
try
{
var entity = ConvertToTenantTableEntity(config);
@@ -71,6 +102,123 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<(TenantConfiguration Config, string ETag)?> GetTenantConfigurationWithEtagAsync(string tenantId)
{
try
{
var entity = await _tenantConfigTableClient.GetEntityAsync<TableEntity>(tenantId, "config");
return (ConvertFromTenantTableEntity(entity.Value), entity.Value.ETag.ToString());
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
// Everything else throws (fail-loud): the transactional caller must never treat a
// storage outage as "no row".
}
public async Task<bool> TryReplaceTenantConfigurationAsync(TenantConfiguration config, string etag)
{
var entity = ConvertToTenantTableEntity(config);
try
{
await _tenantConfigTableClient.UpdateEntityAsync(entity, new ETag(etag), TableUpdateMode.Replace);
return true;
}
catch (RequestFailedException ex) when (ex.Status is 412 or 404)
{
// 412: lost the CAS race — caller re-reads and retries (bounded).
// 404: the row was deleted since the read (offboarding) — the retry's
// re-read surfaces that as "no configuration row".
return false;
}
}
/// <summary>
/// Pre-write snapshot hook shared by the tenant- and admin-config save paths.
/// Fail-SOFT by design: this is a safety net around long-standing writers
/// (portal, plan, auth side effects) — a backup-storage hiccup must never turn
/// into a config-save outage. The transactional patch/revert flow does its own
/// fail-CLOSED snapshot before calling the conditional write and never relies
/// on this hook.
/// </summary>
private async Task TrySnapshotBeforeSaveAsync<TModel>(
TableClient tableClient,
string partitionKey,
string rowKey,
Func<TableEntity, TModel> convertFromEntity,
TModel incoming,
HashSet<string> noiseProperties,
string? changedBy,
string? source,
string? reason) where TModel : class
{
try
{
TableEntity existing;
try
{
existing = (await tableClient.GetEntityAsync<TableEntity>(partitionKey, rowKey)).Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return; // Row creation — nothing to snapshot.
}
// Compare at MODEL level, not raw-entity level: retrieved entities carry
// DateTimeOffset/typing quirks that would false-positive against a freshly
// built entity. The model round-trip puts both sides on identical CLR types.
var before = convertFromEntity(existing);
var changed = ConfigPropertyComparer.GetChangedPropertyNames(before, incoming);
changed.ExceptWith(noiseProperties);
if (changed.Count == 0)
return; // Noise-only or no-op save — don't burn a backup slot.
await _backupRepo.UpsertAsync(BuildBackupEntry(
existing, partitionKey, changedBy,
writeSource: source ?? "unknown", reason: reason,
diffJson: JsonSerializer.Serialize(ConfigDiffHelper.GetChanges(before, incoming))));
await _backupRepo.PruneAsync(partitionKey, Constants.ConfigBackupKeepCount);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Config backup snapshot failed for {PartitionKey} — proceeding with save (fail-soft)",
partitionKey);
}
}
/// <summary>
/// Snapshots the raw stored row (same fidelity approach as the offboarding
/// customs archive): every table property except the Azure pseudo-properties,
/// serialized as JSON. Restore therefore survives model refactors.
/// </summary>
internal static ConfigBackupEntry BuildBackupEntry(
TableEntity sourceRow, string partitionKey, string? changedBy,
string writeSource, string? reason, string? diffJson)
{
var snapshot = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var kv in sourceRow)
{
if (kv.Key is "odata.etag" or "Timestamp") continue;
snapshot[kv.Key] = kv.Value;
}
return new ConfigBackupEntry
{
PartitionKey = partitionKey,
RowKey = TableConfigBackupRepository.BuildRowKey(DateTime.UtcNow),
TenantId = partitionKey,
EntityJson = JsonSerializer.Serialize(snapshot),
ChangedBy = string.IsNullOrWhiteSpace(changedBy) ? "system" : changedBy,
Source = writeSource,
Reason = reason,
DiffJson = diffJson,
BackupTakenAt = DateTime.UtcNow,
};
}
/// <summary>
/// Conditional, single-property seed of ContactEmail (see <see cref="IConfigRepository"/>).
/// Merge mode leaves every other stored property untouched, and the ETag precondition turns
@@ -181,6 +329,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public async Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config)
{
await TrySnapshotBeforeSaveAsync(
_adminConfigTableClient, "GlobalConfig", "config",
ConvertFromAdminTableEntity, config, AdminBackupNoiseProperties,
config.UpdatedBy, "admin-config", reason: null);
try
{
var entity = ConvertToAdminTableEntity(config);
@@ -226,13 +379,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ApprovedBy = addedBy
};
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
// Conditional INSERT, not upsert: the storage layer arbitrates concurrent
// activations (double signup → two auto-approve envelopes), so exactly one
// caller sees "newly added" and runs the side effects (welcome mail, ops event).
await _previewWhitelistTableClient.AddEntityAsync(entity);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
return false;
}
catch (Exception ex)
{
// Throw, don't swallow: activation is the one step that must fail loud —
// a false-y return here used to let callers send the welcome mail for a
// tenant that was never actually activated.
_logger.LogError(ex, "Error adding tenant {TenantId} to preview whitelist", tenantId);
return false;
throw;
}
}
@@ -370,6 +533,51 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
}
// --- Welcome Email Sent Marker ---
public async Task<bool> TryMarkWelcomeEmailSentAsync(string tenantId)
{
var entity = new TableEntity(tenantId, "welcome-email-sent")
{
{ "SentAt", DateTime.UtcNow }
};
try
{
// Conditional INSERT: arbitrates the send race between the approval path and
// the notification-email save path — exactly one caller wins and sends.
await _previewWhitelistTableClient.AddEntityAsync(entity);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking welcome email sent for tenant {TenantId}", tenantId);
throw;
}
}
public async Task ClearWelcomeEmailSentMarkerAsync(string tenantId)
{
try
{
await _previewWhitelistTableClient.DeleteEntityAsync(tenantId, "welcome-email-sent");
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// already gone
}
catch (Exception ex)
{
// Fail-soft like the revoke path itself: a stale marker only suppresses a
// courtesy mail on a later re-approve.
_logger.LogWarning(ex, "Error clearing welcome email marker for tenant {TenantId}", tenantId);
}
}
// --- Tenant Configuration Entity Mapping ---
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
@@ -452,6 +660,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "UnrestrictedMode", config.UnrestrictedMode },
{ "EntraAppRolesEnabled", config.EntraAppRolesEnabled },
{ "OnboardedAt", config.OnboardedAt },
{ "HomedAppClientId", config.HomedAppClientId },
{ "LastAuthClientId", config.LastAuthClientId },
{ "LastAuthClientIdSince", config.LastAuthClientIdSince },
{ "PlanTier", config.PlanTier },
{ "TrialExpiresUtc", config.TrialExpiresUtc },
{ "TrialStartedUtc", config.TrialStartedUtc },
@@ -557,6 +768,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false,
EntraAppRolesEnabled = entity.GetBoolean("EntraAppRolesEnabled") ?? false,
OnboardedAt = entity.GetDateTime("OnboardedAt"),
// Null = legacy app registration (rows pre-dating the C4A8 app-reg move).
HomedAppClientId = entity.GetString("HomedAppClientId"),
LastAuthClientId = entity.GetString("LastAuthClientId"),
LastAuthClientIdSince = entity.GetDateTime("LastAuthClientIdSince"),
PlanTier = entity.GetString("PlanTier") ?? "free",
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
@@ -578,7 +793,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// --- Admin Configuration Entity Mapping ---
private TableEntity ConvertToAdminTableEntity(AdminConfiguration config)
internal static TableEntity ConvertToAdminTableEntity(AdminConfiguration config)
{
var entity = new TableEntity("GlobalConfig", "config")
{
@@ -598,6 +813,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "NvdApiKey", config.NvdApiKey },
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
{ "SelfServiceAppHomingEnabled", config.SelfServiceAppHomingEnabled },
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
{ "MsrcLastSyncUtc", config.MsrcLastSyncUtc },
@@ -641,7 +858,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return entity;
}
private AdminConfiguration ConvertFromAdminTableEntity(TableEntity entity)
internal static AdminConfiguration ConvertFromAdminTableEntity(TableEntity entity)
{
return new AdminConfiguration
{
@@ -663,6 +880,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
NvdApiKey = entity.GetString("NvdApiKey"),
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
SelfServiceAppHomingEnabled = entity.GetBoolean("SelfServiceAppHomingEnabled") ?? false,
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
MsrcLastSyncUtc = entity.GetString("MsrcLastSyncUtc"),
@@ -213,6 +213,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType
};
// Diagnostics-copy columns only exist on rows where the copy was requested —
// legacy-shaped rows stay untouched and map back to null.
if (metadata.DiagnosticsBlobName != null)
entity["DiagnosticsBlobName"] = metadata.DiagnosticsBlobName;
if (metadata.DiagnosticsCopyStatus != null)
entity["DiagnosticsCopyStatus"] = metadata.DiagnosticsCopyStatus;
await _reportsTableClient.UpsertEntityAsync(entity);
return true;
}
@@ -365,8 +372,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
AdminNote = entity.GetString("AdminNote") ?? string.Empty,
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType,
DiagnosticsBlobName = NullIfEmpty(entity.GetString("DiagnosticsBlobName")),
DiagnosticsCopyStatus = NullIfEmpty(entity.GetString("DiagnosticsCopyStatus"))
};
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrEmpty(value) ? null : value;
}
}
}
@@ -55,11 +55,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId)
=> _storage.GetSoftwareInventoryAsync(tenantId);
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync()
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null)
{
var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory);
var results = new List<Dictionary<string, object>>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000))
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000, select: selectColumns))
{
var item = new Dictionary<string, object>();
foreach (var prop in entity)
@@ -31,6 +31,9 @@ public class AutopilotDeviceValidationConsentFunction
private readonly IGraphFeatureDetector _graphFeatureDetector;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
private readonly EntraAppRegistry _appRegistry;
private readonly TenantConfigurationService _tenantConfigService;
private readonly AppHomingService _appHomingService;
/// <summary>
/// Known redirect URIs registered in the Entra ID app registration for the admin consent flow.
@@ -49,14 +52,20 @@ public class AutopilotDeviceValidationConsentFunction
GraphTokenService graphTokenService,
IGraphFeatureDetector graphFeatureDetector,
TelemetryClient telemetryClient,
OpsEventService opsEventService)
OpsEventService opsEventService,
EntraAppRegistry appRegistry,
TenantConfigurationService tenantConfigService,
AppHomingService appHomingService)
{
_appHomingService = appHomingService;
_logger = logger;
_configuration = configuration;
_graphTokenService = graphTokenService;
_graphFeatureDetector = graphFeatureDetector;
_telemetryClient = telemetryClient;
_opsEventService = opsEventService;
_appRegistry = appRegistry;
_tenantConfigService = tenantConfigService;
}
[Function("GetAutopilotDeviceValidationConsentUrl")]
@@ -67,7 +76,16 @@ public class AutopilotDeviceValidationConsentFunction
// Authentication enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var validatorClientId = _configuration["EntraId:ClientId"];
// Dual app-reg window: by default the consent URL targets the app registration that
// actually mints this tenant's Graph tokens. When the self-service homing funnel is on,
// legacy-homed tenants are funneled to the PRIMARY app instead — the subsequent
// consent-status / access-check verification then auto-flips their homing (re-consent,
// verify, and flip collapse into the one flow the admin already runs).
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
var funnel = await _appHomingService.IsFunnelEligibleAsync(tenantConfig);
var validatorClientId = funnel
? _appRegistry.Primary.ClientId
: _appRegistry.ResolveForTenant(tenantConfig).ClientId;
if (string.IsNullOrWhiteSpace(validatorClientId))
{
var badConfig = req.CreateResponse(HttpStatusCode.InternalServerError);
@@ -114,14 +132,15 @@ public class AutopilotDeviceValidationConsentFunction
$"&state={Uri.EscapeDataString("autopilot-device-validation-enable")}";
_logger.LogInformation(
"ConsentFlowStarted: tenant={TenantId} user={UserId} redirectUri={RedirectUri}",
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri);
"ConsentFlowStarted: tenant={TenantId} user={UserId} redirectUri={RedirectUri} funnel={Funnel}",
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri, funnel);
_telemetryClient.TrackEvent("ConsentFlowStarted", new Dictionary<string, string>
{
["TenantId"] = requestCtx.TargetTenantId,
["UserId"] = requestCtx.UserPrincipalName,
["RedirectUri"] = redirectUri,
["Funnel"] = funnel.ToString(),
});
await _opsEventService.RecordConsentFlowStartedAsync(
@@ -130,7 +149,8 @@ public class AutopilotDeviceValidationConsentFunction
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
consentUrl
consentUrl,
willAutoFlipHoming = funnel
});
return response;
}
@@ -143,6 +163,15 @@ public class AutopilotDeviceValidationConsentFunction
// Authentication enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
// Consent-driven homing auto-flip BEFORE the status read: after the funnel minted the
// consent URL for the PRIMARY app, a still-legacy-homed tenant would resolve the status
// against the legacy app and report "not consented" — aborting the frontend flow. Flipping
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
// where the consent-propagation retry chain is exactly what we want to ride.
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
var flipOutcome = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
_telemetryClient.TrackEvent("ConsentStatusChecked", new Dictionary<string, string>
@@ -157,7 +186,8 @@ public class AutopilotDeviceValidationConsentFunction
await response.WriteAsJsonAsync(new
{
isConsented = result.IsConsented,
message = result.Message
message = result.Message,
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
});
return response;
}
@@ -176,10 +206,13 @@ public class AutopilotDeviceValidationConsentFunction
/// opening both the UI badge AND the agent hard gate — without ever running the redirect.
/// </para>
/// <para>
/// Read-only: this endpoint never persists. It checks the SP token's <c>roles</c> claim (via
/// <see cref="IGraphFeatureDetector"/>) rather than mere token-acquirability, so we never flip
/// the gate "enabled" when the SP exists but the role was not actually consented (which would
/// otherwise leave the agent stuck in an endless 503 at the real Graph call).
/// It checks the SP token's <c>roles</c> claim (via <see cref="IGraphFeatureDetector"/>)
/// rather than mere token-acquirability, so we never flip the gate "enabled" when the SP
/// exists but the role was not actually consented (which would otherwise leave the agent
/// stuck in an endless 503 at the real Graph call). The validation gate itself is never
/// persisted here; the only write is the idempotent, flag-gated app-homing reconcile below
/// (a tenant that consented the primary app gets its homing flipped — precedent: this
/// endpoint already mutates the Graph caches on every click).
/// </para>
/// </summary>
[Function("GetAutopilotDeviceValidationAccessCheck")]
@@ -190,18 +223,26 @@ public class AutopilotDeviceValidationConsentFunction
// Authentication + tenant-admin policy enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
// Always read fresh. This probe is admin-triggered (reconcile on consent-fail, the
// "detect existing access" button, post-consent verification) and low-frequency, so a
// stale cached snapshot must never decide it. In particular, a snapshot acquired during
// AAD consent propagation can carry an empty roles claim and would otherwise be cached for
// the token lifetime (~55 min) — wedging the admin out long after consent has propagated.
// Invalidating here makes every click re-acquire from AAD, so a retry always reflects reality.
_graphFeatureDetector.InvalidateTenant(requestCtx.TargetTenantId);
bool isTransient;
bool accessPresent;
AppHomingAutoFlipOutcome flipOutcome;
using (var budgetCts = new CancellationTokenSource(AccessCheckBudget))
{
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
// access" / reconcile path never touches consent-status, so this is its flip hook. A
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
flipOutcome = await _appHomingService.TryAutoFlipToPrimaryAsync(
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
// Always read fresh. This probe is admin-triggered (reconcile on consent-fail, the
// "detect existing access" button, post-consent verification) and low-frequency, so a
// stale cached snapshot must never decide it. In particular, a snapshot acquired during
// AAD consent propagation can carry an empty roles claim and would otherwise be cached for
// the token lifetime (~55 min) — wedging the admin out long after consent has propagated.
// Invalidating here makes every click re-acquire from AAD, so a retry always reflects reality.
_graphFeatureDetector.InvalidateTenant(requestCtx.TargetTenantId);
GraphPermissionSnapshot snapshot;
try
{
@@ -230,6 +271,7 @@ public class AutopilotDeviceValidationConsentFunction
accessPresent,
isTransient,
requiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
});
return response;
}
@@ -68,11 +68,11 @@ public class DelegatedAdminManagementFunction
if (validationError != null)
return await Bad(req, validationError);
// Edition note: TARGET tenants may be any edition — an MSP on Enterprise may manage Community
// customers. The Enterprise requirement applies to the delegated admin's HOME tenant and is
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
// customers. The Pro requirement applies to the delegated admin's HOME tenant and is
// enforced at resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid). It cannot
// be checked here: at grant time only the UPN is known, and UPN-domain → tenant mapping is not
// reliable (multi-domain tenants). Grants to non-Enterprise-homed admins are simply inert.
// reliable (multi-domain tenants). Grants to non-Pro-homed admins are simply inert.
var entry = await _delegatedAdminService.UpsertAsync(
body!.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
@@ -120,8 +120,7 @@ public class TenantOffboardFunction
// 2.b — Capture the Preview-Notification-Email before Phase 2.D wipes the
// PreviewWhitelist table. Stored on the OffboardingHistory row (survives the wipe)
// and consumed by the post-completion farewell-email side-effect in
// TenantOffboardingHandler (currently disarmed by ResendEmailService's
// OffboardFarewellEmailArmed gate). Fail-soft: null on lookup failure — the
// TenantOffboardingHandler. Fail-soft: null on lookup failure — the
// farewell email is a courtesy, not a correctness contract.
var notificationEmail = await CaptureNotificationEmailAsync(normalizedTenantId);
@@ -615,7 +614,7 @@ public class TenantOffboardFunction
var writtenAt = DateTime.UtcNow;
tenantConfig.LastUpdated = writtenAt;
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig);
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig, "offboard", "Disabled-gate before cascade");
if (!saved)
{
// TableConfigRepository.SaveTenantConfigurationAsync returns false on transient
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -62,9 +62,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
return badReq;
}
// Check if bootstrap token feature is enabled for this tenant
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
@@ -93,9 +93,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
Success = true,
ShortCode = session.ShortCode,
// www directly — the bare apex is only a registrar-level 301 to www,
// so pointing there just adds a redirect hop for every bootstrap.
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
// go domain — Front Door rewrites /{code} to /api/bootstrap/go/{code}
// (GetBootstrapScriptFunction). URLs issued before the migration keep
// working on the legacy www /go route until the static-export cutover.
BootstrapUrl = $"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}",
ExpiresAt = session.ExpiresAt,
Message = $"Bootstrap session created. Valid for {validityHours} hours."
};
@@ -0,0 +1,142 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/go/{code} — the OOBE bootstrap script endpoint.
/// Customer-facing as <c>irm https://go.autopilotmonitor.com/{code} | iex</c>
/// (Front Door rewrites /{code} on the go domain to this route). Successor of the
/// portal's Next.js /go/[code] route, moved here so the portal can become a static
/// export; validation happens in-process (BootstrapSessionService), no HTTP hop.
///
/// Contract: ALWAYS HTTP 200 with text/plain — error conditions return a small
/// PowerShell script whose Write-Host surfaces the message, so irm | iex shows the
/// error on the OOBE console instead of crashing on a non-2xx status.
/// Cache-Control: no-store comes from NoStoreCacheMiddleware (the script embeds the
/// bearer token); X-Content-Type-Options: nosniff from SecurityHeadersMiddleware.
/// </summary>
public class GetBootstrapScriptFunction
{
private readonly ILogger<GetBootstrapScriptFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly RateLimitService _rateLimitService;
private readonly TenantConfigurationService _configService;
// Same gate the portal route enforced: 4-10 alphanumeric chars, checked before
// any service call. Stricter than the length-only check in
// ValidateBootstrapCodeFunction on purpose.
private static readonly Regex CodeFormatRegex = new(
"^[a-z0-9]{4,10}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static bool IsValidCodeFormat(string? code) =>
!string.IsNullOrEmpty(code) && CodeFormatRegex.IsMatch(code);
public GetBootstrapScriptFunction(
ILogger<GetBootstrapScriptFunction> logger,
BootstrapSessionService bootstrapService,
RateLimitService rateLimitService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_rateLimitService = rateLimitService;
_configService = configService;
}
[Function("GetBootstrapScript")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/go/{code}")] HttpRequestData req,
string code)
{
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
// On breach the response is still a 200 error script (irm | iex must surface
// the message); Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("Bootstrap script rate limit hit from IP {ClientIp}", clientIp);
var limited = await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Rate limit exceeded. Try again later."));
if (rateLimitResult.RetryAfter.HasValue)
limited.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
return limited;
}
if (!IsValidCodeFormat(code))
{
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format."));
}
var session = await _bootstrapService.ValidateCodeAsync(code);
if (session == null)
{
_logger.LogWarning("Bootstrap script request failed for code {Code} from IP {ClientIp}", code, clientIp);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
var agentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}";
var expiresAtUtc = session.ExpiresAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(session.ExpiresAt, DateTimeKind.Utc)
: session.ExpiresAt.ToUniversalTime();
if (!BootstrapScriptValueValidator.TryValidate(
session.TenantId, session.Token, agentDownloadUrl, expiresAtUtc,
out var values, out var reason))
{
// Log the failure category only — never the offending value, to keep
// potential attack payloads out of Application Insights.
_logger.LogWarning("Bootstrap script value validation failed for code {Code}: {Reason}", code, reason);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap response failed validation. Contact support."));
}
var script = OobeBootstrapScriptGenerator.GenerateSuccessScript(values, DateTime.UtcNow);
return await ScriptResponseAsync(req, script);
}
catch (Exception ex)
{
// Never inline exception detail into the script — it would execute on the
// OOBE console and could leak internals.
_logger.LogError(ex, "Error generating bootstrap script for code {Code}", code);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Failed to generate bootstrap script. Please try again."));
}
}
private static async Task<HttpResponseData> ScriptResponseAsync(HttpRequestData req, string script)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
await response.WriteStringAsync(script);
return response;
}
}
}
@@ -41,9 +41,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TargetTenantId;
// Check if bootstrap token feature is enabled for this tenant
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
// Return empty list instead of error — feature simply not visible
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);

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