mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52ae6541a7 | |||
| d763a885e2 | |||
| eedca11214 | |||
| 73a6817713 | |||
| 0a0c323f73 | |||
| bac26def52 | |||
| 4c70ea8a0a | |||
| eaef1601d0 | |||
| 51737174b2 | |||
| c04280cf68 | |||
| d055eed074 |
+1
-1
Submodule .claude/commands updated: 3e4d919c98...ad441d6577
@@ -143,3 +143,29 @@ jobs:
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shared-manifests-in-sync:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# JSON ↔ C# parity is the dotnet job's SharedManifestParityTests; this job pins
|
||||
# JSON ↔ generated TS (shared-manifests.generated.ts, wire-types.generated.ts and the
|
||||
# MCP's wire-types + wire-vocabularies copies - the vocabularies are what the MCP
|
||||
# tool input schemas derive their z.enum() from).
|
||||
- name: Regenerate
|
||||
run: node scripts/generate-shared-manifest-types.js
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
uses: github/codeql-action/init@v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
uses: github/codeql-action/analyze@v4.37.9
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
|
||||
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
|
||||
timestamp: 2026-08-30
|
||||
timestamp: 2026-09-01
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -139,6 +139,22 @@ regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
|
||||
flip-backs). Kill switch: app setting `CmTraceSkewTripwireDisabled`. Goal state: the event
|
||||
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
|
||||
|
||||
Samples are windowed to the session's most recent INGEST ERA before any of that is
|
||||
computed: the batch stamps of both sides are walked backwards from the newest one and the
|
||||
era ends at the first `ReceivedAt` gap wider than 2 h. A session partition is not one agent
|
||||
run — a pre-provisioning session's Part 1 is written by whatever agent build was current
|
||||
weeks earlier, and its events stay in the partition forever. Field 2026-09-01 (sessions
|
||||
`e797117b` / `c06d639d` / `d7c8032b`, one tenant): 26 IME samples at exactly −60 min from a
|
||||
2026-08-20 technician leg under agent 2.0.1409 — before per-line anchoring — outnumbered the
|
||||
3…9 clean samples of the user leg that completed that morning and fired the tripwire against
|
||||
2.0.1445, a build the device had self-updated to minutes earlier. The skew was real, 12 days
|
||||
old, and already fixed; no scan-wide statistic can tell those apart, because event rows carry
|
||||
no agent version. The era boundary can. `eraStartUtc` and the excluded per-side sample counts
|
||||
travel in the ops event, so an inherited leg stays visible without being alarmed on. The
|
||||
2 h threshold sits above any in-leg upload gap (a live agent uploads self-metrics every few
|
||||
minutes, reports `session_stalled` at 60 min idle, and the maintenance sweep classifies
|
||||
silence at 2 h) and below every pre-provisioning handover.
|
||||
|
||||
Device-clock problems (the customer-actionable cousin) are covered separately by the
|
||||
`clock_skew` analyze condition behind `ANALYZE-DEV-008`, which excludes IME-derived events
|
||||
precisely so an anchoring regression can never surface as a customer finding.
|
||||
|
||||
@@ -251,6 +251,21 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
|
||||
one-shot terminal-stage hook (raises the agent's `Terminated` event off-thread).
|
||||
- The `DeadlineScheduler`'s `Fired` event loops back in as synthetic `DeadlineFired`
|
||||
signals.
|
||||
- **Pipeline health in `agent_metrics_snapshot`** (2026-09-01, motivated by the WG-seal
|
||||
soak in which a frozen engine and a deliberate Weak sealing verdict were telemetrically
|
||||
indistinguishable): every snapshot carries monotonic totals from `SignalIngress`
|
||||
(`signal_last_ordinal`, `signal_processed_total`, `signal_pending_count`,
|
||||
`signal_reentrant_total`, `signal_queue_peak` — a monotonic peak, deliberately no
|
||||
momentary queue length, so snapshot sampling cannot alias it) plus `decision_stage`,
|
||||
`decision_last_applied_ordinal` and — once the sealing classifier has produced a
|
||||
verdict — `wg_sealing_level`/`wg_sealing_score` from the immutable `DecisionState`.
|
||||
Two snapshots therefore prove whether the worker ran in between: a frozen worker keeps
|
||||
`signal_pending_count` growing while the processed/applied counters stand still. The
|
||||
probe chain is read-only (`EnrollmentOrchestrator` → `IComponentFactory.
|
||||
CreateCollectorHosts(decisionStateProbe)` → `PeriodicCollectorLifecycleHost` →
|
||||
`AgentSelfMetricsCollector`); the snapshot event itself travels the collector/event
|
||||
path, which is independent of the signal pipeline it measures — field-proven by the
|
||||
freeze cases, whose perf/metrics events kept flowing while the engine stood still.
|
||||
- On restart, `RecoveryCoordinator` folds the persisted signal log through a transient
|
||||
`new DecisionEngine()` before the live pipeline starts — see
|
||||
[logs & persistence](logs-and-persistence.md).
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Ops-Events Query Surface
|
||||
description: The server-side filter surface of GET /api/global/ops-events (category, eventType, severity/minSeverity, tenant drill, date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set, and why the paged cross-category read has to fan out over the complete category vocabulary.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- table-storage
|
||||
- ops-events
|
||||
- pagination
|
||||
- mcp
|
||||
timestamp: 2026-09-01T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
`OpsEvents` is the platform operator's log: consent flows, maintenance runs, security blocks,
|
||||
tenant offboards, agent emergency breaks, SLA and Azure Monitor alerts. It is partitioned by
|
||||
**category** (`PartitionKey`), with a reverse-tick `RowKey` for newest-first ordering.
|
||||
|
||||
Until 2026-09-01 the read endpoint could only narrow by category and date window. Every other
|
||||
question — *"did any `AgentEmergencyBreak` fire yesterday?"*, *"show me everything at Error or
|
||||
worse"* — had to be answered by pulling the whole category and filtering it on the client. For the
|
||||
MCP tool that client is a language model, so an event-type question cost a category-sized response
|
||||
in model context.
|
||||
|
||||
# Schema
|
||||
|
||||
`GET /api/global/ops-events` (GA + Global Reader; `TenantScoping.None`, so a delegated admin cannot
|
||||
reach it at all):
|
||||
|
||||
| Param | Where it is applied | Notes |
|
||||
| --- | --- | --- |
|
||||
| `category` | `PartitionKey eq` — indexed | One partition; omit to fan out over all of them |
|
||||
| `eventType` | `EventType eq` — server-side scan | **Exact and case-sensitive**; Table Storage cannot fold case |
|
||||
| `severity` | `Severity eq` — server-side scan | Normalised onto the canonical vocabulary before use |
|
||||
| `minSeverity` | OR-set over `Severity` — server-side scan | Threshold: that level and everything above |
|
||||
| `dateFrom` / `dateTo` | RowKey range — indexed | Both absent ⇒ backend resolves the last 30 days |
|
||||
| `tenantId` | **in-memory**, post-fetch | Drill, not an isolation boundary — see below |
|
||||
| `pageSize` / `continuation` | — | `pageSize` opts into pagination; absent ⇒ full window, no cap |
|
||||
|
||||
The filter surface lives in two shared pieces so no path can honour a different one:
|
||||
`OpsEventFilterRequest` (query → `OpsEventQueryFilters` + pagination extras) and
|
||||
`TableOpsEventRepository.AppendFieldFilters` (filters → OData clauses), which **both** filter
|
||||
builders call — the single-category/unpaged builder and the per-partition fan-out builder that adds
|
||||
the `RowKey gt` bound. This mirrors `AuditLogFilterRequest` / `AuditLogQueryFilters` on the audit
|
||||
endpoints one-for-one.
|
||||
|
||||
## Why `minSeverity` is an OR-set, not a range
|
||||
|
||||
Table Storage compares strings lexicographically, and the severity names sort
|
||||
`Critical < Error < Info < Warning` — nothing to do with severity order. A `Severity ge 'Warning'`
|
||||
clause would return `Warning` only, silently dropping `Error` and `Critical`, which is precisely the
|
||||
alert-worthy half. `minSeverity` therefore expands to
|
||||
`(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')`. The ladder itself lives
|
||||
in `OpsEventSeverity.Rank` and is shared with `OpsAlertDispatchService`, so *"Warning and above"* in
|
||||
a read means exactly what *"Warning and above"* meant when the alert rules decided to fire.
|
||||
|
||||
`minSeverity=Info` emits no clause at all — it is the floor, so the clause would only cost query
|
||||
length.
|
||||
|
||||
## Why severity is normalised but eventType is not
|
||||
|
||||
Both columns are matched with `eq`, which is case-sensitive. Severity has a closed vocabulary of
|
||||
four values, so `?severity=warning` is normalised to `Warning`, and anything outside the vocabulary
|
||||
is a **400** — an unnormalised value would return an empty page that reads exactly like *"no such
|
||||
events happened"*.
|
||||
|
||||
Event types have a declared vocabulary too (`OpsEventTypes`, reflected into the shared manifest and
|
||||
served to the model as `get_resource(name="ops_event_types")`), but the filter deliberately does
|
||||
**not** validate against it: stored rows outlive the vocabulary, so a type that was retired after a
|
||||
refactor must stay searchable in the historical window. The value is passed verbatim and a typo
|
||||
legitimately returns zero rows — the catalog is there so nobody has to guess.
|
||||
|
||||
## Continuation fingerprint
|
||||
|
||||
Every filter value is appended to the pagination *extras*, which are both folded into the
|
||||
continuation-token fingerprint and echoed on `nextLink`. Consequences:
|
||||
|
||||
* a token minted for one filter cannot page a different one (it is rejected with a 400),
|
||||
* the follow-up request is self-contained — the resolved date window and the filters travel on the
|
||||
link, so no page re-resolves "now",
|
||||
* extras are appended **after** the pre-existing `category` / `tenantId` discriminators and skipped
|
||||
when empty, so tokens minted before the field filters existed still fingerprint identically.
|
||||
|
||||
Because severity values are normalised *before* fingerprinting, `?severity=error` and
|
||||
`?severity=Error` share one token instead of minting two that each reject the other.
|
||||
|
||||
## The tenant drill stays in memory
|
||||
|
||||
`?tenantId=` is filtered post-fetch (`GetOpsEventsFunction.FilterByTenant`) because OpsEvents is
|
||||
partitioned by category, and — deliberately — because the in-memory comparison is
|
||||
`OrdinalIgnoreCase` while an OData `eq` would be case-sensitive: stored tenant casing is not
|
||||
guaranteed, so pushing this one down would lose rows. It is drill *correctness*, not isolation:
|
||||
every caller that reaches this route already has full cross-tenant scope. Pages therefore can report
|
||||
fewer items than `pageSize` when the drill is narrow.
|
||||
|
||||
## The category vocabulary must be complete
|
||||
|
||||
The paged cross-category read does not issue one cross-partition query (Azure would page it by
|
||||
`PartitionKey asc`, so the first page would come entirely from the alphabetically first category).
|
||||
It fans out per partition over a fixed list and merge-sorts. That list is therefore load-bearing: a
|
||||
category that is written but not listed is **invisible to every paged reader** while the unpaged
|
||||
full-table path still returns it. `Platform` (Azure Monitor alerts relayed through the ops alert
|
||||
webhook) was missing that way from its introduction until 2026-09-01.
|
||||
|
||||
The list is now owned by `OpsEventCategory.All`, and `OpsEventCategoryCoverageTests` reflects over
|
||||
the constants to fail the build when a new category is not added to it.
|
||||
|
||||
# Examples
|
||||
|
||||
```
|
||||
# One event type, last 24h, cross-category
|
||||
GET /api/global/ops-events?eventType=AgentEmergencyBreak&dateFrom=2026-08-31T12:00:00Z&pageSize=200
|
||||
|
||||
# Everything alert-worthy in the maintenance partition this month
|
||||
GET /api/global/ops-events?category=Maintenance&minSeverity=Error&dateFrom=2026-08-01T00:00:00Z
|
||||
```
|
||||
|
||||
MCP `get_ops_events` exposes the same surface plus a `days` shorthand, which it resolves to a
|
||||
concrete `dateFrom` **client-side** — a backend-side "last N days" would re-resolve "now" on every
|
||||
page and blow the token fingerprint. An explicit `dateFrom`/`dateTo` always wins over the shorthand.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` — the declared event-type vocabulary
|
||||
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs`
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts`
|
||||
* [Business Timestamps](business-timestamps.md) — why the date window filters on the RowKey
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Ops Notification Channels
|
||||
description: How platform alerts are routed to named channels instead of three fixed provider slots — the shared NotificationChannel model on both the tenant and platform side, one NotificationChannelDispatcher that routes each channel to its transport, the legacy-slot synthesis that keeps pre-migration dispatch behaviour identical, the empty-means-all rule binding, why Telegram is Global-Admin-only by construction, and why an ops event's structured payload only reaches a destination when a rule opts in.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs
|
||||
tags:
|
||||
- backend
|
||||
- notifications
|
||||
- ops-events
|
||||
- configuration
|
||||
- security
|
||||
timestamp: 2026-09-01T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Platform (ops) alerting had no routing. An `OpsAlertRule` was
|
||||
`{ EventType, MinSeverity, Enabled }` with no destination, and `OpsAlertDispatchService`
|
||||
broadcast every matching rule to all three hard-wired slots in `AdminConfiguration`
|
||||
(`OpsAlertTelegramChatId`, `OpsAlertTeamsWebhookUrl`, `OpsAlertSlackWebhookUrl`). Three
|
||||
consequences followed:
|
||||
|
||||
1. **No per-event destinations.** Binding a conversion signal to a sales channel meant every
|
||||
other rule reached that channel too.
|
||||
2. **Only the first matching rule was evaluated** (`FirstOrDefault`), so two rules on the same
|
||||
event type could not coexist.
|
||||
3. **The payload never left the backend.** `OpsEventService.WriteAsync` serialized a `details`
|
||||
object into the `OpsEvents` table but passed only `(category, eventType, severity, message,
|
||||
tenantId)` to the dispatch — a receiving system saw a tenant GUID and a sentence.
|
||||
|
||||
The tenant side already had the right model: `NotificationChannel` (a named list in
|
||||
`TenantConfiguration.NotificationChannelsJson`) with analyze rules binding channels by id
|
||||
(`RuleState.NotifyChannelIdsJson`). The platform side needed that model, not a second one.
|
||||
|
||||
# Schema
|
||||
|
||||
## One model, two scopes
|
||||
|
||||
`NotificationChannel` is unchanged and now backs both lists:
|
||||
|
||||
| Scope | Storage | Bound by |
|
||||
| --- | --- | --- |
|
||||
| Tenant | `TenantConfiguration.NotificationChannelsJson` | per-channel `NotifyOn*` toggles + analyze rules (`NotifyChannelIdsJson`) |
|
||||
| Platform | `AdminConfiguration.OpsNotificationChannelsJson` | `OpsAlertRule.NotifyChannelIds` |
|
||||
|
||||
The scopes stay separate on purpose — platform events resolve against platform channels, and a
|
||||
tenant's channel list can never receive an ops alert. What is shared is the *model* and the
|
||||
*dispatch path*, so a new provider is added once.
|
||||
|
||||
## One dispatch path
|
||||
|
||||
`NotificationChannelDispatcher` is the channel-level send API. It routes by provider:
|
||||
`WebhookProviderType.Telegram` goes to `TelegramNotificationService`; everything else is a
|
||||
rendered webhook POST through `WebhookNotificationService`. Every caller that sends to
|
||||
configured channels — enrollment (`EventIngestProcessor`), session start
|
||||
(`RegisterSessionFunction`), hardware rejection (`ReportDistressFunction`), SLA
|
||||
(`SlaBreachEvaluationService`), analyze rules (`AnalyzeOnEnrollmentEndHandler`), ops alerts
|
||||
(`OpsAlertDispatchService`) and both test endpoints — goes through it.
|
||||
|
||||
## Telegram is Global-Admin-only by construction
|
||||
|
||||
Telegram (`WebhookProviderType.Telegram = 40`) is the odd provider: it is not a webhook. The
|
||||
channel's `Url` carries the destination **chat ID**, and the bot token is platform-owned
|
||||
(PreviewConfig `WebhookUrl`). A tenant admin configuring one would be sending through *our*
|
||||
bot, which is why the gate is a server-side rule, not a hidden dropdown option:
|
||||
|
||||
* `TenantConfigValidation.ValidateTelegramChannelGate` refuses a non-GA caller who **adds** a
|
||||
Telegram channel or changes an existing one's destination or enabled state. It lives in
|
||||
`ValidateModel`, so it covers both write paths — the full-model PUT *and* the MCP field-patch
|
||||
flow (`TenantConfigPatchService`). A check placed in `UpdateTenantConfigurationFunction` alone
|
||||
would not.
|
||||
* An **unchanged** GA-created channel passes, so a tenant admin can still save unrelated config.
|
||||
* Telegram destinations skip `SsrfGuard.ValidateWebhookUrlFormat` (a chat ID is not a URL and
|
||||
would always fail it) and are format-checked as a numeric id or `@username` instead.
|
||||
|
||||
## Migration without a data job
|
||||
|
||||
`AdminConfiguration.GetOpsNotificationChannels()` returns the stored list, or — when it is empty —
|
||||
synthesizes one channel per *configured* legacy slot with stable ids (`legacy-telegram`,
|
||||
`legacy-teams`, `legacy-slack`) and the legacy enabled flags. `GetAdminConfigurationFunction`
|
||||
projects the same synthesis into the read model, so the editor shows exactly what receives
|
||||
alerts today and the first save materializes it. The legacy fields stay as the migration source
|
||||
and are no longer written by the Alerts section.
|
||||
|
||||
## Rule binding: empty means all
|
||||
|
||||
`OpsAlertRule.NotifyChannelIds` null or empty = every enabled channel. That is what every rule
|
||||
written before routing existed carries, so behaviour on the deploy day is identical. Dispatch
|
||||
resolves **all** matching rules (`Where`, not `FirstOrDefault`) and unions their channels;
|
||||
ids that no longer resolve are dropped rather than falling back to broadcast — a rule whose only
|
||||
channel was deleted must reach nothing, not everything. The web editor mirrors the same rule in
|
||||
`app/admin/components/opsChannelRouting.ts`.
|
||||
|
||||
## Payload delivery is opt-in per rule
|
||||
|
||||
`WriteAsync` hands the serialized `details` to `DispatchAsync`, but a channel only receives it when
|
||||
a rule sets `OpsAlertRule.IncludePayload`. **Default false**: an ops alert is a "something happened,
|
||||
go look" signal, most payloads are operational noise in a chat, and some carry data the baseline
|
||||
never does (tenant domain, administrator contact address). Widening what leaves the platform stays
|
||||
a deliberate per-rule decision.
|
||||
|
||||
`ResolveTargets` therefore returns two groups. Channels reached only by plain rules get an alert
|
||||
built with `detailsJson: null`; channels reached by an opted-in rule get the enriched one. A channel
|
||||
in both groups appears in the payload group only — one message per channel, and an unrelated
|
||||
sibling rule cannot cancel an explicit opt-in.
|
||||
|
||||
For the enriched alert, `BuildAlert`:
|
||||
|
||||
* flattens top-level **scalars** into `NotificationFact`s — capped at 12 facts and 256 characters,
|
||||
nulls and nested values skipped — so card formats and the plain-text Telegram message carry the
|
||||
same information,
|
||||
* passes the raw JSON through `NotificationAlert.DataJson`, which only `GenericJsonRenderer`
|
||||
emits, as a real JSON object under `data`. Additive within `schemaVersion` 1.0.
|
||||
|
||||
`NotificationAlert.EventType` is set either way: it is the same value the `Event` fact already
|
||||
carries, so it adds no information — only a stable key for a generic consumer to branch on.
|
||||
|
||||
# Examples
|
||||
|
||||
A trial-conversion signal reaching only a sales webhook:
|
||||
|
||||
1. Admin → Alerts → add a channel `Sales` (Generic JSON, the internal endpoint).
|
||||
2. Enable the `TenantTrialStarted` rule at `Info`, select **only** `Sales`, and switch its
|
||||
**Payload** toggle on (off by default, so every other rule keeps the baseline alert).
|
||||
3. `PlanManagementFunction` fires `TenantTrialStarted` from both plan write paths — the
|
||||
self-service `POST config/{tenantId}/trial` and a GA grant via `PATCH config/{tenantId}/plan`
|
||||
(distinguished by `selfService`). Ending a trial is a downgrade and stays with
|
||||
`TenantPlanDowngraded`.
|
||||
4. The webhook receives `eventType`, the rendered facts, and `data` with `domainName`,
|
||||
`contactEmail`, `trialStartedUtc`, `trialExpiresUtc`, `grantedBy`, `selfService`. The operator
|
||||
push channel receives nothing, because it is not bound to that rule.
|
||||
|
||||
# Gotchas
|
||||
|
||||
* **A new ops event type must be dual-registered** in `OPS_EVENT_TYPES`
|
||||
(`app/admin/components/OpsAlertRulesSection.tsx`) or it cannot be selected. `OpsEventTypeDualRegisterTests`
|
||||
enforces it.
|
||||
* **`OpsNotificationChannelsJson` is secret-bearing** and is on the deny-list in
|
||||
`AdminConfiguration.RedactedCopyForReader()`. `NotificationChannel.RedactList` /
|
||||
`RestoreRedactedList` are shared with the tenant list — do not add a second copy.
|
||||
* **Details are capped at 4096 characters** in the `OpsEvents` table (`TableOpsEventRepository`)
|
||||
before they ever reach a channel.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs`
|
||||
+97
-11
@@ -1,14 +1,14 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
|
||||
title: Drift Guards — SignalR Names, RowKeys, Wire DTOs, Tool Vocabularies, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, the MCP's generated tool vocabularies with their inline-enum ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs
|
||||
tags:
|
||||
- contracts
|
||||
- guard-tests
|
||||
- signalr
|
||||
- web
|
||||
timestamp: 2026-08-13T00:00:00+02:00
|
||||
timestamp: 2026-08-31T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Drift Guards
|
||||
@@ -49,13 +49,94 @@ restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic surviv
|
||||
solely as the fallback for manifests written before the field existed and must outlive
|
||||
one full manifest-retention cycle.
|
||||
|
||||
## Anonymous OkAsync responses — frozen, shrink-only
|
||||
## Wire DTOs — generated interfaces, zero anonymous success bodies
|
||||
|
||||
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
|
||||
sites as a per-file baseline. A new anonymous response (new file or count above
|
||||
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
|
||||
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
|
||||
list stays truthful and only ever shrinks.
|
||||
Every HTTP success body is a flat class in `AutopilotMonitor.Shared.Models`
|
||||
implementing the `IApiResponse` marker (2026-08-31, `feat/typed-api-contract` —
|
||||
migrated from the 44 `OkAsync(new { … })` + 134 raw `WriteAsJsonAsync(new { … })`
|
||||
sites the 2026-08-13 audit froze). Three layers enforce it:
|
||||
|
||||
- **Compile time**: the only `OkAsync/CreatedAsync/JsonAsync` overloads constrain on
|
||||
`IApiResponse` — an anonymous object cannot satisfy the constraint. Error bodies
|
||||
(first key `error`/`message`, or literal `success = false`) stay anonymous by design.
|
||||
- **`TypedResponseGuardTests`** (successor of `OkAsyncBaselineGuardTests`): both
|
||||
per-file baselines are EMPTY — any anonymous success body, through the helpers or
|
||||
raw `WriteAsJsonAsync`, is a straight failure. A reflection fact keeps every
|
||||
`IApiResponse` implementer flat (System.Text.Json serializes derived properties
|
||||
before base ones — key order is wire contract; MCP hands raw JSON to an LLM) and
|
||||
inside the Shared assembly.
|
||||
- **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal
|
||||
vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case
|
||||
per key that WhenWritingNull omits.
|
||||
- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over
|
||||
the reachable wire-type graph fails on any `object` / collection-of-`object`
|
||||
property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`,
|
||||
heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt.
|
||||
The former deliberately-untyped slots (RuleDryRun trace, HealthCheck,
|
||||
GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports,
|
||||
MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw
|
||||
table entities left the wire entirely in the same pass: global-admins,
|
||||
tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` /
|
||||
`TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT
|
||||
partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON
|
||||
shape tests instead of parity facts.
|
||||
|
||||
The DTO rules: declaration order == wire order; a property is nullable exactly when a
|
||||
site can emit null (the key then vanishes — never add non-null defaults that would
|
||||
invent a key); preformatted strings (`.ToString("o")`, enum `.ToString()`) stay
|
||||
`string`; `fields=` projections stay `IReadOnlyList<object>` tagged
|
||||
`[ProjectedItems(typeof(Item))]`; raw table rows stay verbatim-keyed dictionaries
|
||||
(no `DictionaryKeyPolicy` — dictionary KEYS never run through the camelCase policy,
|
||||
and dictionary VALUES keep explicit nulls, both pinned by tests).
|
||||
|
||||
`SharedManifestParityTests` (schemaVersion 2) reflects the full graph — every
|
||||
`IApiResponse` implementer plus `[WireContract]`-marked payload types, transitively
|
||||
closed by `WireTypeManifestBuilder` — into the `types` section of
|
||||
`shared-manifests.json`, carrying C# `<summary>` texts. The web codegen
|
||||
(`npm run generate:manifests`) emits `utils/wire-types.generated.ts` (one interface
|
||||
per object, one string union per enum, JSDoc from the summaries); the hand-written
|
||||
`types/session.ts` / `types/enrollment.ts` / `types/adminConfig.ts` re-export from
|
||||
it. Freshness is pinned by the vitest suite and the `shared-manifests-in-sync` CI
|
||||
job. Unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the
|
||||
manifest build instead of degrading to `unknown`.
|
||||
|
||||
## Tool vocabularies — the MCP derives them, it no longer retypes them
|
||||
|
||||
Wire TYPES reached the MCP from day one (`src/generated/wire-types.generated.ts`, the
|
||||
same codegen output the web gets). Vocabularies did not: types are erased at runtime and
|
||||
a `z.enum([...])` needs values, so every MCP tool schema hand-typed its own copy of
|
||||
statuses, severities and categories — with nothing to compare against. The copies drifted
|
||||
exactly as you would expect: `search_sessions` offered 5 of 8 session statuses (no
|
||||
`AwaitingUser`, no `Incomplete`), both event readers offered 4 of 6 severities (no `Debug`,
|
||||
no `Trace`), and `get_ops_events` named 6 ops categories while the backend wrote 7. None of
|
||||
that surfaces as an error — the tool just quietly cannot ask for the missing value.
|
||||
|
||||
The vocabularies now ship as VALUES. `MCP_VOCABULARIES` in the codegen maps manifest
|
||||
sections onto `src/generated/wire-vocabularies.generated.ts` (`export const
|
||||
SESSION_STATUSES = [...] as const` plus its union type); the tool schemas do
|
||||
`z.enum(SESSION_STATUSES)` and descriptions interpolate the list instead of spelling it.
|
||||
Freshness is pinned twice: `wire-types-freshness.test.ts` compares the committed file
|
||||
byte-for-byte against a fresh codegen run, and the `shared-manifests-in-sync` CI job
|
||||
diffs it after regenerating.
|
||||
|
||||
`vocabulary-drift.guard.test.ts` is the ratchet that keeps it that way: every inline
|
||||
`z.enum([...])` left in `src/tools/*.ts` must be on a baseline that names why it has no
|
||||
backend owner (MCP-local knobs like `depth: fast|deep`, plus three free-string columns —
|
||||
rule type, connection type, CVE risk — that have no C# constants class yet). A new
|
||||
hand-typed list fails until it is derived or reviewed onto the baseline, and a stale
|
||||
baseline entry fails too.
|
||||
|
||||
Ops event types got the missing owner: they were bare literals at the `OpsEventService`
|
||||
call sites, so nothing could enumerate them and both the portal alert-rule catalog and the
|
||||
MCP had to retype the list. `OpsEventTypes` (Shared) now declares all 77; the write sites
|
||||
use the constants (a raw literal fails `OpsEventTypeDualRegisterTests`), the manifest
|
||||
exports them, and `get_resource(name="ops_event_types")` serves categories + severities +
|
||||
types to the model from the generated copy.
|
||||
|
||||
Backend-internal lists follow the same rule: a list that enumerates a constants class IS
|
||||
the class. `TableOpsEventRepository.AllCategories` is `OpsEventCategory.All` rather than a
|
||||
retyped copy — the copy had gone stale and silently hid every `Platform` (Azure Monitor)
|
||||
event from the paged cross-category read while the unpaged path still showed it.
|
||||
|
||||
## Tenant settings — per-section PATCH
|
||||
|
||||
@@ -98,9 +179,14 @@ list in `sectionFieldMap.test.ts`.
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
|
||||
- `src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js` — `MCP_VOCABULARIES`, the vocabulary codegen
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts` — the inline-enum ratchet
|
||||
- `src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` + `OpsEventTypeDualRegisterTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs` + `WireTypeManifestBuilder.cs` + the `*WireParityTests` files
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/CommonApiModels.cs` — `IApiResponse`, `[WireContract]`, `[ProjectedItems]`
|
||||
- `src/Web/autopilot-monitor-web/utils/wire-types.generated.ts` (via `scripts/generate-shared-manifest-types.js`)
|
||||
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
|
||||
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
|
||||
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs (IApiResponse + generated wire-types.generated.ts, empty anonymous-body baselines), the MCP's generated tool vocabularies (wire-vocabularies.generated.ts + the inline-z.enum ratchet), per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
|
||||
# Agent
|
||||
|
||||
@@ -54,6 +54,8 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
|
||||
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Ops-Events Query Surface](backend/ops-events-query-surface.md) - The server-side filter surface of the ops-events read (category, eventType, severity/minSeverity, in-memory tenant drill, RowKey date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set instead of a lexicographic range, and why the paged cross-category fan-out must cover the complete category vocabulary (Platform was missing).
|
||||
* [Ops Notification Channels](backend/ops-notification-channels.md) - Platform alerts route to NAMED channels instead of three fixed provider slots: the tenant-side NotificationChannel model now backs both scopes, one NotificationChannelDispatcher routes each channel to its transport (Telegram is not a webhook - chat id, platform-owned bot, Global-Admin-only enforced in ValidateModel so the MCP patch path is covered too), legacy slots are synthesized with stable ids so pre-migration dispatch is unchanged, a rule's empty channel binding means all, and an ops event's structured details reach the destination as facts plus a `data` object only when a rule opts in (IncludePayload, off by default, so the baseline alert is unchanged).
|
||||
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
|
||||
|
||||
+28
@@ -1,5 +1,33 @@
|
||||
# Log
|
||||
|
||||
## 2026-09-01 (3)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` - agent_metrics_snapshot gains a signal-pipeline health block: monotonic SignalIngress totals (last ordinal, processed, pending, reentrant, queue peak) + decision_stage / decision_last_applied_ordinal / wg_sealing_level+score from the immutable DecisionState, wired via a read-only decisionStateProbe through IComponentFactory. Motivated by the WG-seal soak (project_wg_seal_postfix_soak_residual): a frozen engine and a deliberate Weak sealing verdict were previously indistinguishable in telemetry.
|
||||
|
||||
## 2026-09-01 (4)
|
||||
|
||||
* **Update**: `drift-guards.md` — MCP tool vocabularies are generated, not retyped. The codegen now emits `wire-vocabularies.generated.ts` (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) alongside the MCP's wire types, byte-pinned by the MCP freshness suite and the `shared-manifests-in-sync` CI job; the tool schemas derive their `z.enum()` from it. Found by the switch: `search_sessions` had offered 5 of 8 session statuses (no `AwaitingUser`/`Incomplete`) and both event readers 4 of 6 severities (no `Debug`/`Trace`). `vocabulary-drift.guard.test.ts` ratchets the remaining inline enums against a reasoned baseline. New `OpsEventTypes` (Shared) gives the 77 ops event types a canonical home — `OpsEventService` write sites use the constants (a raw literal now fails the dual-register test), and `get_resource(name="ops_event_types")` serves the vocabulary to the model.
|
||||
|
||||
## 2026-09-01 (3)
|
||||
|
||||
* **New**: `backend/ops-notification-channels.md` - platform alerting gains named channels. `AdminConfiguration.OpsNotificationChannelsJson` reuses the tenant `NotificationChannel` model (redaction/restore helpers hoisted onto the model so both scopes share one implementation); `OpsAlertRule.NotifyChannelIds` binds rules to channels with empty = all, and dispatch now evaluates ALL matching rules (`Where`, not `FirstOrDefault`) and unions their targets, dropping ids that no longer resolve instead of falling back to broadcast. New `NotificationChannelDispatcher` is the single channel-level send API for every caller (enrollment, session start, hardware rejection, SLA, analyze rules, ops alerts, both test endpoints). New provider `WebhookProviderType.Telegram = 40` carries a chat id rather than a URL and sends through the platform-owned bot - Global-Admin-only, enforced in `TenantConfigValidation.ValidateModel` so the MCP field-patch path is gated too, not just the PUT. Legacy provider slots are synthesized into channels with stable ids (`legacy-telegram` named "Autopilot Monitor IT Engineer") and projected into the GET read model, so day-one behaviour is unchanged and the first save materializes the list. New ops event `TenantTrialStarted` fires from both plan write paths, and `OpsEventService.WriteAsync` hands the serialized details to the dispatch - but delivery is opt-in per rule (`OpsAlertRule.IncludePayload`, default false), so every existing alert keeps its category/event/severity/tenant baseline and the trust-page description of operator alerting stays accurate. `ResolveTargets` splits targets into a payload group and a plain group (a channel in both gets the payload once); for the enriched alert `BuildAlert` flattens top-level scalars into facts (12 / 256 chars) and passes the raw object to `GenericJsonRenderer` as `data` (additive, schemaVersion stays 1.0). `eventType` is set either way - same value the `Event` fact already carried.
|
||||
|
||||
## 2026-09-01 (2)
|
||||
|
||||
* **New**: `backend/ops-events-query-surface.md` - the ops-events read gains server-side `eventType` / `severity` / `minSeverity` filters (audit-log filter pattern: one parse helper, one clause appender used by both filter builders, values folded into the continuation fingerprint and echoed on nextLink). `minSeverity` expands to an OR-set because the severity names sort `Critical < Error < Info < Warning` lexicographically; the ladder is now shared with `OpsAlertDispatchService`. Fixed alongside: the paged cross-category fan-out skipped `Platform` (Azure Monitor alerts) because its category list had drifted from `OpsEventCategory` - the list is now `OpsEventCategory.All` with a reflection coverage test. MCP `get_ops_events` exposes the filters plus a client-resolved `days` shorthand.
|
||||
|
||||
## 2026-09-01 (1)
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — the backend skew tripwire now judges only the session's most recent ingest era (batch stamps walked back from the newest, split at the first `ReceivedAt` gap > 2 h). Pre-provisioning partitions hold a Part 1 leg written weeks earlier by an older agent build: on 2026-09-01 three sessions of one tenant fired on 26 IME samples at −60 min left by agent 2.0.1409 on 2026-08-20 (pre per-line anchoring), against a device already self-updated to 2.0.1445. `SessionSkewScan` carries `ReceivedAt` per sample (distinct-batch counts derive from the era-filtered set; the cap keeps the newest samples), and the ops event reports `eraStartUtc` plus the excluded per-side counts.
|
||||
|
||||
## 2026-08-31 (2)
|
||||
|
||||
* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests.
|
||||
|
||||
## 2026-08-31 (1)
|
||||
|
||||
* **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `<summary>` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job.
|
||||
|
||||
## 2026-08-30 (15)
|
||||
|
||||
* **Update**: `backend/lifecycle-manifests-and-session-scope.md` — SessionTenantLookup is now claimed first-writer-wins at registration (create-only Add BEFORE any session write; foreign owner ⇒ 409 `session_owner_mismatch` + `SessionTenantConflict` ops event, nothing written; not fail-soft). Closes the poisoning path where any tenant's device could re-point a victim sessionId's mapping at its own tenant and hijack every no-tenantId Global Admin/Reader read and GA write (annotation lane). `ResolveSessionScopeAsync(requireGlobalAdmin: true)` corroborates the resolved tenant against its Sessions row (silent — no operator confirmation). Self-heal write is Add-only; `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the legacy window once. No tombstone on deletion (deliberate: unbounded growth for no gain).
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the signal-pipeline block of <c>agent_metrics_snapshot</c> (2026-09-01 WG-seal
|
||||
/// soak: sessions whose DecisionEngine never sealed were telemetrically
|
||||
/// indistinguishable from a deliberate Weak classifier verdict, because the snapshots
|
||||
/// carried no pipeline counters). The fields are monotonic totals plus a monotonic
|
||||
/// queue peak — deliberately no momentary queue length — so two snapshots prove
|
||||
/// whether the ingress worker ran in between.
|
||||
/// </summary>
|
||||
public sealed class AgentSelfMetricsCollectorSignalPipelineTests
|
||||
{
|
||||
private static AgentLogger NewLogger(string path)
|
||||
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
|
||||
|
||||
private static (AgentSelfMetricsCollector Collector, FakeSignalIngressSink Sink) Build(
|
||||
string tmpPath, Func<SignalPipelineHealth?>? probe)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(sink, SystemClock.Instance);
|
||||
var collector = new AgentSelfMetricsCollector(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: post,
|
||||
networkMetrics: new NetworkMetrics(),
|
||||
logger: NewLogger(tmpPath),
|
||||
agentVersion: "2.0.test",
|
||||
signalPipelineProbe: probe);
|
||||
return (collector, sink);
|
||||
}
|
||||
|
||||
private static IDictionary<string, object> CollectSnapshotData(
|
||||
AgentSelfMetricsCollector collector, FakeSignalIngressSink sink)
|
||||
{
|
||||
collector.CollectSafe();
|
||||
var snapshot = sink.Posted.Single(p =>
|
||||
p.TypedPayload is IDictionary<string, object> d
|
||||
&& d.ContainsKey("agent_version"));
|
||||
return (IDictionary<string, object>)snapshot.TypedPayload!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_carries_pipeline_counters_stage_and_wg_verdict()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
LastAssignedSignalOrdinal = 41,
|
||||
ProcessedSignalCount = 42,
|
||||
PendingSignalCount = 3,
|
||||
ReentrantPostCount = 7,
|
||||
QueueLengthPeak = 12,
|
||||
DecisionStage = "EspDeviceSetup",
|
||||
LastAppliedSignalOrdinal = 40,
|
||||
WhiteGloveSealingLevel = "Weak",
|
||||
WhiteGloveSealingScore = 40,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal(41L, data["signal_last_ordinal"]);
|
||||
Assert.Equal(42L, data["signal_processed_total"]);
|
||||
Assert.Equal(3L, data["signal_pending_count"]);
|
||||
Assert.Equal(7L, data["signal_reentrant_total"]);
|
||||
Assert.Equal(12L, data["signal_queue_peak"]);
|
||||
Assert.Equal("EspDeviceSetup", data["decision_stage"]);
|
||||
Assert.Equal(40L, data["decision_last_applied_ordinal"]);
|
||||
Assert.Equal("Weak", data["wg_sealing_level"]);
|
||||
Assert.Equal(40, data["wg_sealing_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_derived_fields_are_omitted_when_the_probe_has_no_state()
|
||||
{
|
||||
// Counters-only health (test fakes without a decision-state probe): the numeric
|
||||
// block still lands, the state-derived keys must be ABSENT — not null-valued.
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
ProcessedSignalCount = 5,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal(5L, data["signal_processed_total"]);
|
||||
Assert.False(data.ContainsKey("decision_stage"));
|
||||
Assert.False(data.ContainsKey("decision_last_applied_ordinal"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_level"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_score"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wg_fields_are_omitted_before_the_classifier_produced_a_verdict()
|
||||
{
|
||||
// Level stays null until the classifier ran — every non-WG session would
|
||||
// otherwise carry a meaningless Unknown/0 pair on each snapshot.
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
DecisionStage = "Monitoring",
|
||||
LastAppliedSignalOrdinal = 9,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal("Monitoring", data["decision_stage"]);
|
||||
Assert.False(data.ContainsKey("wg_sealing_level"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_score"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_failure_degrades_to_a_snapshot_without_the_block()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path,
|
||||
() => throw new InvalidOperationException("probe exploded"));
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.True(data.ContainsKey("agent_version")); // snapshot itself survives
|
||||
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_probe_means_no_pipeline_fields()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, probe: null);
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
|
||||
Assert.False(data.ContainsKey("decision_stage"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -64,7 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
|
||||
CapturedIngress = ingress;
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
|
||||
|
||||
@@ -65,6 +65,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return SpinWait.SpinUntil(condition, timeoutMs);
|
||||
}
|
||||
|
||||
// ==================================== Pipeline-health counters (metrics snapshot)
|
||||
|
||||
// The agent_metrics_snapshot exports these as MONOTONIC totals so two snapshots can
|
||||
// prove whether the worker ran in between (2026-09-01 WG-seal soak: a frozen engine
|
||||
// and a deliberate Weak sealing verdict were otherwise indistinguishable).
|
||||
|
||||
[Fact]
|
||||
public void ProcessedSignalCount_counts_fully_processed_signals()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var ing = rig.Build();
|
||||
Assert.Equal(0, ing.ProcessedSignalCount);
|
||||
ing.Start();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"p-{i}"));
|
||||
|
||||
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 5),
|
||||
$"expected 5 processed, saw {ing.ProcessedSignalCount}");
|
||||
Assert.Equal(0, ing.PendingSignalCount);
|
||||
ing.Stop();
|
||||
Assert.Equal(5, ing.ProcessedSignalCount); // stable after drain
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueLengthPeak_is_monotonic_and_survives_drain()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var ing = rig.Build();
|
||||
|
||||
// Post BEFORE Start: the worker is not consuming yet, so the channel depth
|
||||
// deterministically reaches the number of queued items.
|
||||
for (int i = 0; i < 4; i++)
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"q-{i}"));
|
||||
Assert.True(ing.QueueLengthPeak >= 4, $"peak {ing.QueueLengthPeak} < 4");
|
||||
|
||||
ing.Start();
|
||||
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 4));
|
||||
// Drained queue must NOT reset the peak — that is the whole point of exporting
|
||||
// a peak instead of a momentary length (snapshot sampling cannot alias it away).
|
||||
Assert.True(ing.QueueLengthPeak >= 4);
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// ============================================================ Lifecycle
|
||||
|
||||
[Fact]
|
||||
|
||||
+46
-4
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
private readonly ITelemetrySpool? _telemetrySpool;
|
||||
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
|
||||
private readonly Func<Orchestration.SignalPipelineHealth?>? _signalPipelineProbe;
|
||||
|
||||
// Previous sample for delta calculations
|
||||
private TimeSpan _prevCpuTime;
|
||||
@@ -53,13 +54,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
string agentVersion = "unknown",
|
||||
int intervalSeconds = 60,
|
||||
ITelemetrySpool? telemetrySpool = null,
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
|
||||
Func<Orchestration.SignalPipelineHealth?>? signalPipelineProbe = null)
|
||||
: base(sessionId, tenantId, post, logger, intervalSeconds)
|
||||
{
|
||||
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
|
||||
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
|
||||
_telemetrySpool = telemetrySpool;
|
||||
_imeTrackerHealthProbe = imeTrackerHealthProbe;
|
||||
_signalPipelineProbe = signalPipelineProbe;
|
||||
}
|
||||
|
||||
protected override void OnBeforeStart()
|
||||
@@ -82,11 +85,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 29 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// Full path writes 38 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency) + IME tracker health
|
||||
// (9). cap=29 → HashHelpers.GetPrime(29)=29 buckets → no resize on the last key.
|
||||
var data = new Dictionary<string, object>(capacity: 29, StringComparer.Ordinal)
|
||||
// (9) + signal pipeline (9: 5 counters, stage, applied ordinal, wg level+score).
|
||||
// cap=38 → HashHelpers.GetPrime(38)=41 buckets → no resize on the last key.
|
||||
var data = new Dictionary<string, object>(capacity: 38, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -213,6 +217,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signal/decision pipeline health ---
|
||||
// Monotonic totals + a monotonic queue peak, deliberately NO momentary queue
|
||||
// length: a snapshot samples the integral, so any two snapshots prove whether
|
||||
// the ingress worker ran in between. Field motivation (2026-09-01 WG-seal soak):
|
||||
// sessions whose engine never sealed were indistinguishable from a deliberate
|
||||
// Weak classifier verdict without these fields. decision_stage answers the WG
|
||||
// question directly; the wg_sealing_* pair separates "classifier said Weak"
|
||||
// from "classifier never ran".
|
||||
if (_signalPipelineProbe != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pipeline = _signalPipelineProbe();
|
||||
if (pipeline != null)
|
||||
{
|
||||
data["signal_last_ordinal"] = pipeline.LastAssignedSignalOrdinal;
|
||||
data["signal_processed_total"] = pipeline.ProcessedSignalCount;
|
||||
data["signal_pending_count"] = pipeline.PendingSignalCount;
|
||||
data["signal_reentrant_total"] = pipeline.ReentrantPostCount;
|
||||
data["signal_queue_peak"] = pipeline.QueueLengthPeak;
|
||||
if (pipeline.DecisionStage != null)
|
||||
data["decision_stage"] = pipeline.DecisionStage;
|
||||
if (pipeline.LastAppliedSignalOrdinal.HasValue)
|
||||
data["decision_last_applied_ordinal"] = pipeline.LastAppliedSignalOrdinal.Value;
|
||||
if (pipeline.WhiteGloveSealingLevel != null)
|
||||
{
|
||||
data["wg_sealing_level"] = pipeline.WhiteGloveSealingLevel;
|
||||
if (pipeline.WhiteGloveSealingScore.HasValue)
|
||||
data["wg_sealing_score"] = pipeline.WhiteGloveSealingScore.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Signal pipeline health read failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
Post.Emit(new EnrollmentEvent
|
||||
|
||||
@@ -204,7 +204,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
|
||||
@@ -599,7 +600,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
startupGate: _startupEventGate, // M3 — disk_space_low latch survives restarts
|
||||
// IME tracker health rides the metrics snapshot (queue depth + skip counters);
|
||||
// read-only probe, same shape as the registry observer's trackerStateProbe.
|
||||
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth()));
|
||||
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth(),
|
||||
// Stage + WG classifier verdict for the snapshot's signal-pipeline block
|
||||
// (2026-09-01 WG-seal soak: without them a frozen engine and a deliberate
|
||||
// Weak sealing verdict were telemetrically indistinguishable).
|
||||
decisionStateProbe: decisionStateProbe));
|
||||
}
|
||||
|
||||
// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the
|
||||
|
||||
@@ -667,7 +667,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var surfaces = _componentFactory.CreateCollectorHosts(
|
||||
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
|
||||
timelineEvents: _timelineEvents);
|
||||
timelineEvents: _timelineEvents,
|
||||
// Immutable-state reference read; null-safe against a processor that is
|
||||
// torn down while a late metrics tick still fires.
|
||||
decisionStateProbe: () => _processor?.CurrentState);
|
||||
CollectorSurfaces = surfaces;
|
||||
_collectorHosts = surfaces.Hosts;
|
||||
foreach (var host in _collectorHosts)
|
||||
|
||||
+53
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
@@ -56,6 +57,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly ITelemetrySpool? _telemetrySpool;
|
||||
private readonly Persistence.StartupEventGate? _startupGate;
|
||||
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
|
||||
// Read-only probe into the reducer's current DecisionState (stage + WG classifier
|
||||
// verdict) for the signal-pipeline block of agent_metrics_snapshot. DecisionState is
|
||||
// immutable, so a cross-thread reference read yields a consistent (possibly slightly
|
||||
// stale) snapshot — same access pattern the orchestrator's own Stop path uses.
|
||||
private readonly Func<DecisionState?>? _decisionStateProbe;
|
||||
|
||||
// Codex Finding 4 — reference to the concrete SignalIngress (when available) so we
|
||||
// can subscribe to its SignalPosted event. This lets us observe activity from
|
||||
@@ -90,9 +96,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string agentVersion,
|
||||
ITelemetrySpool? telemetrySpool = null,
|
||||
Persistence.StartupEventGate? startupGate = null,
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
_imeTrackerHealthProbe = imeTrackerHealthProbe;
|
||||
_decisionStateProbe = decisionStateProbe;
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_sessionId = sessionId;
|
||||
@@ -198,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
_selfMetricsCollector = new AgentSelfMetricsCollector(
|
||||
_sessionId, _tenantId, _post, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds, _telemetrySpool,
|
||||
imeTrackerHealthProbe: _imeTrackerHealthProbe);
|
||||
imeTrackerHealthProbe: _imeTrackerHealthProbe,
|
||||
signalPipelineProbe: BuildSignalPipelineProbe());
|
||||
_selfMetricsCollector.Start();
|
||||
}
|
||||
_idleStopped = false;
|
||||
@@ -215,6 +224,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_selfMetricsCollector = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the signal-pipeline probe for the metrics snapshot from whatever is
|
||||
/// available: the concrete <see cref="SignalIngress"/> supplies the monotonic
|
||||
/// worker counters, the decision-state probe supplies stage + WG classifier
|
||||
/// verdict. Null only when NEITHER source exists (pure test fakes) — then the
|
||||
/// snapshot simply omits the block, same degrade pattern as the IME health probe.
|
||||
/// </summary>
|
||||
private Func<SignalPipelineHealth?>? BuildSignalPipelineProbe()
|
||||
{
|
||||
var ingress = _observableIngress;
|
||||
var stateProbe = _decisionStateProbe;
|
||||
if (ingress == null && stateProbe == null) return null;
|
||||
|
||||
return () =>
|
||||
{
|
||||
var health = new SignalPipelineHealth();
|
||||
if (ingress != null)
|
||||
{
|
||||
health.LastAssignedSignalOrdinal = ingress.LastAssignedSignalOrdinal;
|
||||
health.ProcessedSignalCount = ingress.ProcessedSignalCount;
|
||||
health.PendingSignalCount = ingress.PendingSignalCount;
|
||||
health.ReentrantPostCount = ingress.ReentrantPostCount;
|
||||
health.QueueLengthPeak = ingress.QueueLengthPeak;
|
||||
}
|
||||
var state = stateProbe?.Invoke();
|
||||
if (state != null)
|
||||
{
|
||||
health.DecisionStage = state.Stage.ToString();
|
||||
health.LastAppliedSignalOrdinal = state.LastAppliedSignalOrdinal;
|
||||
// Only after the classifier produced at least one verdict — otherwise
|
||||
// every non-WG session would carry a meaningless Unknown/0 pair.
|
||||
var sealing = state.ClassifierOutcomes?.WhiteGloveSealing;
|
||||
if (sealing?.LastClassifierVerdictId != null)
|
||||
{
|
||||
health.WhiteGloveSealingLevel = sealing.Level.ToString();
|
||||
health.WhiteGloveSealingScore = sealing.Score;
|
||||
}
|
||||
}
|
||||
return health;
|
||||
};
|
||||
}
|
||||
|
||||
private void OnRealEvent()
|
||||
{
|
||||
lock (_sync)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
@@ -47,6 +48,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="decisionStateProbe"/> is a read-only probe into the reducer's current
|
||||
/// immutable <see cref="AutopilotMonitor.DecisionCore.State.DecisionState"/> — it feeds the
|
||||
/// signal-pipeline block of <c>agent_metrics_snapshot</c> (stage + WG classifier verdict).
|
||||
/// Nullable; fakes pass null and the snapshot omits the state-derived fields.
|
||||
/// </remarks>
|
||||
CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -55,6 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null);
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
|
||||
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
|
||||
private long _reentrantPostCount;
|
||||
private long _queueLengthPeak;
|
||||
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
|
||||
private int _started; // 0 = not started, 1 = running
|
||||
private int _stopRequested; // 0 = live, 1 = Stop called
|
||||
@@ -156,6 +157,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
|
||||
|
||||
/// <summary>
|
||||
/// Total signals the worker has fully processed (reduce + effects). Monotonic — the
|
||||
/// agent_metrics_snapshot exports this so two snapshots prove whether the pipeline
|
||||
/// worked in between (a frozen worker keeps posting-side counters growing while this
|
||||
/// one stands still; a momentary queue-length sample could never show that).
|
||||
/// </summary>
|
||||
public long ProcessedSignalCount => Interlocked.Read(ref _processedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Highest channel queue length observed at any Post since Start. Monotonic
|
||||
/// non-decreasing, so snapshot sampling cannot alias it away (same rationale as the
|
||||
/// spool's PeakPendingItemCount). Worker-local follow-up items never count here.
|
||||
/// </summary>
|
||||
public long QueueLengthPeak => Interlocked.Read(ref _queueLengthPeak);
|
||||
|
||||
/// <summary>
|
||||
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
|
||||
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
|
||||
@@ -317,6 +333,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (added)
|
||||
{
|
||||
UpdateQueueLengthPeak(_channel.Count);
|
||||
RaiseSignalPosted(kind, payload);
|
||||
return;
|
||||
}
|
||||
@@ -324,6 +341,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Slow path: bounded channel full → block, measure duration, notify observer throttled.
|
||||
var blockStartedUtc = _clock.UtcNow;
|
||||
int queueLenAtBlock = _channel.Count;
|
||||
UpdateQueueLengthPeak(queueLenAtBlock);
|
||||
try
|
||||
{
|
||||
_channel.Add(item);
|
||||
@@ -345,6 +363,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
RaiseSignalPosted(kind, payload);
|
||||
}
|
||||
|
||||
/// <summary>Lock-free monotonic max (net48 has no Interlocked max primitive).</summary>
|
||||
private void UpdateQueueLengthPeak(int observedLength)
|
||||
{
|
||||
long observed = observedLength;
|
||||
long current = Interlocked.Read(ref _queueLengthPeak);
|
||||
while (observed > current)
|
||||
{
|
||||
var prior = Interlocked.CompareExchange(ref _queueLengthPeak, observed, current);
|
||||
if (prior == current) return;
|
||||
current = prior;
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
// Snapshot the delegate once — standard pattern to tolerate concurrent subscribe /
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable enable annotations
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time read model of the signal/decision pipeline for the
|
||||
/// <c>agent_metrics_snapshot</c> event. Motivated by the 2026-09-01 WG-seal soak
|
||||
/// (memory: project_wg_seal_postfix_soak_residual): sessions whose DecisionEngine never
|
||||
/// sealed were telemetrically indistinguishable from a deliberate Weak classifier verdict,
|
||||
/// because the snapshots carried no signal-pipeline counters at all.
|
||||
/// <para>
|
||||
/// The counters are MONOTONIC totals, not momentary gauges — a snapshot samples the
|
||||
/// integral, so any two snapshots prove whether the worker ran in between (a frozen
|
||||
/// worker keeps <see cref="PendingSignalCount"/> growing while
|
||||
/// <see cref="ProcessedSignalCount"/> / <see cref="LastAppliedSignalOrdinal"/> stand
|
||||
/// still). Momentary queue length is deliberately exported only as a peak.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SignalPipelineHealth
|
||||
{
|
||||
/// <summary>Last SessionSignalOrdinal assigned by the ingress worker; −1 before the first signal.</summary>
|
||||
public long LastAssignedSignalOrdinal { get; set; }
|
||||
|
||||
/// <summary>Total signals fully processed (reduce + effects). Monotonic.</summary>
|
||||
public long ProcessedSignalCount { get; set; }
|
||||
|
||||
/// <summary>Signals accepted by Post but not yet fully processed (backlog gauge; pairs with the totals).</summary>
|
||||
public long PendingSignalCount { get; set; }
|
||||
|
||||
/// <summary>Worker-thread re-entrant posts routed via the worker-local queue. Monotonic.</summary>
|
||||
public long ReentrantPostCount { get; set; }
|
||||
|
||||
/// <summary>Highest channel queue length observed at any Post since Start. Monotonic non-decreasing.</summary>
|
||||
public long QueueLengthPeak { get; set; }
|
||||
|
||||
/// <summary>Current DecisionState stage name (e.g. "EspDeviceSetup", "WhiteGloveSealed"). Null when no state is available.</summary>
|
||||
public string? DecisionStage { get; set; }
|
||||
|
||||
/// <summary>The DecisionState's LastAppliedSignalOrdinal — the reducer-side twin of the ingress counters. Null when no state is available.</summary>
|
||||
public long? LastAppliedSignalOrdinal { get; set; }
|
||||
|
||||
/// <summary>Last WhiteGlove-sealing classifier level ("Weak", "Confirmed", …). Null until the classifier produced a verdict.</summary>
|
||||
public string? WhiteGloveSealingLevel { get; set; }
|
||||
|
||||
/// <summary>Score of the last WhiteGlove-sealing verdict. Null until the classifier produced a verdict.</summary>
|
||||
public int? WhiteGloveSealingScore { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Admin folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class AdminWireParityTests
|
||||
{
|
||||
private const string SampleTenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
|
||||
// ---- GetActiveUsers ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetActiveUsersResponse_matches_the_presence_listing_shape()
|
||||
{
|
||||
var lastSeen = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
var anonymousUsers = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = SampleTenantId,
|
||||
upn = "admin@contoso.com",
|
||||
userRole = "GlobalAdmin",
|
||||
lastSeen,
|
||||
secondsAgo = 42
|
||||
},
|
||||
}.ToList();
|
||||
var typedUsers = new List<ActiveUserItem>
|
||||
{
|
||||
new ActiveUserItem
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "admin@contoso.com",
|
||||
UserRole = "GlobalAdmin",
|
||||
LastSeen = lastSeen,
|
||||
SecondsAgo = 42
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
windowMinutes = 5,
|
||||
activeCount = anonymousUsers.Count,
|
||||
users = anonymousUsers
|
||||
},
|
||||
new GetActiveUsersResponse
|
||||
{
|
||||
Success = true,
|
||||
WindowMinutes = 5,
|
||||
ActiveCount = typedUsers.Count,
|
||||
Users = typedUsers
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAuditLogs / GetGlobalAuditLogs (non-paged + paged share one DTO) -------------
|
||||
|
||||
private static AuditLogEntry SampleAuditLog() => new AuditLogEntry
|
||||
{
|
||||
Id = "20260830120000000_0001",
|
||||
TenantId = SampleTenantId,
|
||||
Action = "CREATE",
|
||||
EntityType = "DeviceBlock",
|
||||
EntityId = "SN-0042",
|
||||
PerformedBy = "admin@contoso.com",
|
||||
Timestamp = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
Details = "{\"Action\":\"Block\"}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
|
||||
{
|
||||
var logs = new List<AuditLogEntry> { SampleAuditLog() };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = logs.Count, logs },
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = logs.Count,
|
||||
Logs = logs,
|
||||
// NextLink stays null → the key is absent, exactly like the old
|
||||
// non-paged literal that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_matches_the_paged_shape_with_a_nextLink()
|
||||
{
|
||||
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry> { SampleAuditLog() };
|
||||
string? nextLink = "/api/audit/logs?pageSize=50&continuation=abc";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
logs = items,
|
||||
nextLink,
|
||||
},
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Logs = items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_omits_a_null_nextLink_on_the_paged_path()
|
||||
{
|
||||
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
logs = items,
|
||||
nextLink,
|
||||
},
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Logs = items,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetOpsEvents (non-paged + paged share one DTO) ----------------------------------
|
||||
|
||||
private static OpsEventEntry SampleOpsEvent() => new OpsEventEntry
|
||||
{
|
||||
Id = "20260830113000000_0007",
|
||||
Category = "Maintenance",
|
||||
EventType = "maintenance_completed",
|
||||
Severity = "Info",
|
||||
TenantId = SampleTenantId,
|
||||
UserId = "admin@contoso.com",
|
||||
Message = "Maintenance run completed",
|
||||
Details = "{\"durationMs\":1200}",
|
||||
Timestamp = new DateTime(2026, 8, 30, 11, 30, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
|
||||
{
|
||||
var filtered = new List<OpsEventEntry> { SampleOpsEvent() };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = filtered.Count, events = filtered },
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = filtered.Count,
|
||||
Events = filtered,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_matches_the_paged_shape_with_a_nextLink()
|
||||
{
|
||||
var pageItems = new List<OpsEventEntry> { SampleOpsEvent() };
|
||||
string? nextLink = "/api/global/ops-events?pageSize=100&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
events = pageItems,
|
||||
nextLink,
|
||||
},
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Events = pageItems,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_omits_a_null_nextLink_on_the_paged_path()
|
||||
{
|
||||
var pageItems = new List<OpsEventEntry>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
events = pageItems,
|
||||
nextLink,
|
||||
},
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Events = pageItems,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationConsentUrl ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentUrlResponse_matches_the_consent_url_shape()
|
||||
{
|
||||
var consentUrl = "https://login.example/6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/adminconsent?client_id=x";
|
||||
var funnel = true;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
consentUrl,
|
||||
willAutoFlipHoming = funnel
|
||||
},
|
||||
new AutopilotConsentUrlResponse
|
||||
{
|
||||
ConsentUrl = consentUrl,
|
||||
WillAutoFlipHoming = funnel
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationConsentStatus ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentStatusResponse_matches_the_consent_status_shape()
|
||||
{
|
||||
string? message = "Consent verified";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
isConsented = true,
|
||||
message,
|
||||
homingFlipped = false
|
||||
},
|
||||
new AutopilotConsentStatusResponse
|
||||
{
|
||||
IsConsented = true,
|
||||
Message = message,
|
||||
HomingFlipped = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentStatusResponse_omits_a_null_message()
|
||||
{
|
||||
string? message = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
isConsented = false,
|
||||
message,
|
||||
homingFlipped = false
|
||||
},
|
||||
new AutopilotConsentStatusResponse
|
||||
{
|
||||
IsConsented = false,
|
||||
Message = null,
|
||||
HomingFlipped = false
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationAccessCheck -----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotAccessCheckResponse_matches_the_access_check_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
accessPresent = true,
|
||||
isTransient = false,
|
||||
requiredPermission = "DeviceManagementServiceConfig.Read.All",
|
||||
homingFlipped = true,
|
||||
},
|
||||
new AutopilotAccessCheckResponse
|
||||
{
|
||||
AccessPresent = true,
|
||||
IsTransient = false,
|
||||
RequiredPermission = "DeviceManagementServiceConfig.Read.All",
|
||||
HomingFlipped = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ----------------
|
||||
|
||||
[Fact]
|
||||
public void BackfillJobRunResponse_matches_the_backfill_shape()
|
||||
{
|
||||
var result = new BackfillResult
|
||||
{
|
||||
Table = "audit",
|
||||
DryRun = true,
|
||||
RowsExamined = 250,
|
||||
WouldWrite = 12,
|
||||
Written = 0,
|
||||
SkippedAlreadySet = 230,
|
||||
SkippedUndecodable = 8,
|
||||
Errors = 0,
|
||||
NextContinuation = "token123",
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new BackfillJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReclassifyJobRunResponse_matches_the_reclassify_shape()
|
||||
{
|
||||
var result = new ReclassificationResult
|
||||
{
|
||||
Mode = "legacy_timeouts",
|
||||
DryRun = false,
|
||||
TenantsExamined = 3,
|
||||
SessionsExamined = 120,
|
||||
WouldChange = 0,
|
||||
Changed = 7,
|
||||
ToSucceeded = 5,
|
||||
ToIncomplete = 2,
|
||||
KeptFailed = 110,
|
||||
Skipped = 3,
|
||||
Errors = 0,
|
||||
CapReached = false,
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new ReclassifyJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TriggerMaintenance --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerMaintenanceResponse_matches_the_manual_trigger_shape()
|
||||
{
|
||||
var result = new MaintenanceResult
|
||||
{
|
||||
Success = true,
|
||||
TriggeredBy = "admin@contoso.com",
|
||||
TriggeredAt = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc),
|
||||
DurationMs = 1234,
|
||||
StalledSessionsChecked = true,
|
||||
MetricsAggregated = true,
|
||||
AggregatedDate = "2026-08-29",
|
||||
DataCleanupExecuted = false,
|
||||
PlatformStatsRecomputed = true,
|
||||
DevicesBlockedForExcessiveData = 0,
|
||||
ContactEmailsBackfilled = 2,
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 10, 5, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Maintenance tasks completed",
|
||||
result = result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt
|
||||
},
|
||||
new TriggerMaintenanceResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Maintenance tasks completed",
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- CustomsArchive (list runs / list entries / get entry / delete run) --------------
|
||||
// The run/entry item classes moved verbatim (same property names + declaration order)
|
||||
// from CustomsArchiveQueryFunction.RunSummary/EntrySummary into the Shared models, so
|
||||
// the same instances serve as the item payload on both sides of the envelope proof.
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveRunListResponse_matches_the_run_listing_shape()
|
||||
{
|
||||
var runs = new List<CustomsArchiveRunSummary>
|
||||
{
|
||||
new CustomsArchiveRunSummary
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
TenantId = SampleTenantId,
|
||||
HistoryRowKey = "20260829120000",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
GatherRulesCount = 4,
|
||||
AnalyzeRulesCount = 2,
|
||||
ImeLogPatternsCount = 1,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = runs.Count, runs },
|
||||
new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveEntryListResponse_matches_the_entry_listing_shape()
|
||||
{
|
||||
var items = new List<CustomsArchiveEntrySummary>
|
||||
{
|
||||
new CustomsArchiveEntrySummary
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
RowKey = "GatherRules_R0FUSEVSLTE",
|
||||
OriginalTable = "GatherRules",
|
||||
OriginalRowKey = "GATHER-1",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
EntityJsonPreview = "{\"RuleId\":\"GATHER-1\"}",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = items.Count, entries = items },
|
||||
new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveEntryResponse_matches_the_full_entry_shape()
|
||||
{
|
||||
var entry = new TenantOffboardingCustomsArchiveEntry
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
RowKey = "AnalyzeRules_QU5BTFlaRS0x",
|
||||
TenantId = SampleTenantId,
|
||||
OriginalTable = "AnalyzeRules",
|
||||
OriginalPartitionKey = SampleTenantId,
|
||||
OriginalRowKey = "ANALYZE-1",
|
||||
EntityJson = "{\"RuleId\":\"ANALYZE-1\",\"Name\":\"Sample\"}",
|
||||
HistoryRowKey = "20260829120000",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, entry },
|
||||
new CustomsArchiveEntryResponse { Success = true, Entry = entry });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveDeleteEntry_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveDeleteRunResponse_matches_the_bulk_delete_shape()
|
||||
{
|
||||
var deleted = 7;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, deleted },
|
||||
new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted });
|
||||
}
|
||||
|
||||
// ---- DelegatedAdminManagement --------------------------------------------------------
|
||||
|
||||
private static DelegatedAdminEntry SampleDelegatedAdmin() => new DelegatedAdminEntry
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
TenantId = SampleTenantId,
|
||||
Role = "DelegatedReader",
|
||||
IsEnabled = true,
|
||||
Status = "Active",
|
||||
Source = "OperatorGranted",
|
||||
GrantedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
GrantedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void DelegatedAdminListResponse_matches_the_assignments_shape()
|
||||
{
|
||||
var assignments = new List<DelegatedAdminEntry> { SampleDelegatedAdmin() };
|
||||
|
||||
AssertParity(
|
||||
new { assignments },
|
||||
new DelegatedAdminListResponse { Assignments = assignments });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelegatedAdminGrantResponse_matches_the_single_assignment_shape()
|
||||
{
|
||||
var entry = SampleDelegatedAdmin();
|
||||
|
||||
AssertParity(
|
||||
new { assignment = entry },
|
||||
new DelegatedAdminGrantResponse { Assignment = entry });
|
||||
}
|
||||
|
||||
// ---- DeviceBlock / GetAllBlockedDevices ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BlockedDeviceListResponse_matches_the_blocked_listing_shape()
|
||||
{
|
||||
var blocked = new List<BlockedDeviceEntry>
|
||||
{
|
||||
new BlockedDeviceEntry
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
SerialNumber = "SN-0042",
|
||||
BlockedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
|
||||
UnblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc),
|
||||
BlockedByEmail = "admin@contoso.com",
|
||||
DurationHours = 12,
|
||||
Reason = "Excessive data",
|
||||
Action = "Block",
|
||||
BlockedSessionIds = null,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, blocked },
|
||||
new BlockedDeviceListResponse { Success = true, Blocked = blocked });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockDeviceResponse_matches_the_block_acknowledgement_shape()
|
||||
{
|
||||
var serialNumber = "SN-0042";
|
||||
var durationHours = 12;
|
||||
var isKill = false;
|
||||
var action = "Block";
|
||||
var unblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = isKill
|
||||
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
|
||||
: $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
unblockAt,
|
||||
action
|
||||
},
|
||||
new BlockDeviceResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
UnblockAt = unblockAt,
|
||||
Action = action
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockDevice_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var serialNumber = "SN-0042";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, message = $"Device {serialNumber} unblocked." },
|
||||
new SuccessMessageResponse { Success = true, Message = $"Device {serialNumber} unblocked." });
|
||||
}
|
||||
|
||||
// ---- EmailTemplates ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResponse_matches_the_effective_template_shape_with_an_override()
|
||||
{
|
||||
string? updatedBy = "admin@contoso.com";
|
||||
DateTime? updatedUtc = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kind = "welcome",
|
||||
subject = "Welcome to Autopilot Monitor",
|
||||
isOverridden = true,
|
||||
html = "<html>override</html>",
|
||||
builtInHtml = "<html>{{domainName}}</html>",
|
||||
updatedBy,
|
||||
updatedUtc,
|
||||
placeholder = "{{domainName}}",
|
||||
maxLength = 30_000,
|
||||
},
|
||||
new EmailTemplateResponse
|
||||
{
|
||||
Kind = "welcome",
|
||||
Subject = "Welcome to Autopilot Monitor",
|
||||
IsOverridden = true,
|
||||
Html = "<html>override</html>",
|
||||
BuiltInHtml = "<html>{{domainName}}</html>",
|
||||
UpdatedBy = updatedBy,
|
||||
UpdatedUtc = updatedUtc,
|
||||
Placeholder = "{{domainName}}",
|
||||
MaxLength = 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResponse_omits_updatedBy_and_updatedUtc_without_an_override()
|
||||
{
|
||||
string? updatedBy = null;
|
||||
DateTime? updatedUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kind = "farewell",
|
||||
subject = "Farewell from Autopilot Monitor",
|
||||
isOverridden = false,
|
||||
html = "<html>{{domainName}}</html>",
|
||||
builtInHtml = "<html>{{domainName}}</html>",
|
||||
updatedBy,
|
||||
updatedUtc,
|
||||
placeholder = "{{domainName}}",
|
||||
maxLength = 30_000,
|
||||
},
|
||||
new EmailTemplateResponse
|
||||
{
|
||||
Kind = "farewell",
|
||||
Subject = "Farewell from Autopilot Monitor",
|
||||
IsOverridden = false,
|
||||
Html = "<html>{{domainName}}</html>",
|
||||
BuiltInHtml = "<html>{{domainName}}</html>",
|
||||
UpdatedBy = null,
|
||||
UpdatedUtc = null,
|
||||
Placeholder = "{{domainName}}",
|
||||
MaxLength = 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateSaveResponse_matches_the_save_acknowledgement_shape()
|
||||
{
|
||||
var updatedUtc = new DateTime(2026, 8, 30, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new { kind = "welcome", isOverridden = true, updatedBy = "admin@contoso.com", updatedUtc },
|
||||
new EmailTemplateSaveResponse { Kind = "welcome", IsOverridden = true, UpdatedBy = "admin@contoso.com", UpdatedUtc = updatedUtc });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResetResponse_matches_the_reset_acknowledgement_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new { kind = "farewell", isOverridden = false },
|
||||
new EmailTemplateResetResponse { Kind = "farewell", IsOverridden = false });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateTestSendResponse_matches_the_test_send_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new { sentTo = "it@contoso.com", domainName = "contoso.com", draft = true },
|
||||
new EmailTemplateTestSendResponse { SentTo = "it@contoso.com", DomainName = "contoso.com", Draft = true });
|
||||
}
|
||||
|
||||
// ---- IdentityBindingManagement -------------------------------------------------------
|
||||
|
||||
private static AdminIdentityBinding SampleBinding() => new AdminIdentityBinding
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
TenantId = "0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
|
||||
ObjectId = "b1b1b1b1-1111-4222-8333-444455556666",
|
||||
BoundBy = "admin@contoso.com",
|
||||
BoundAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
ObjectIdPinnedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IdentityBindingListResponse_matches_the_bindings_shape()
|
||||
{
|
||||
var bindings = new List<AdminIdentityBinding> { SampleBinding() };
|
||||
|
||||
AssertParity(
|
||||
new { bindings },
|
||||
new IdentityBindingListResponse { Bindings = bindings });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityBindingResponse_matches_the_single_binding_shape()
|
||||
{
|
||||
var binding = SampleBinding();
|
||||
|
||||
AssertParity(
|
||||
new { binding },
|
||||
new IdentityBindingResponse { Binding = binding });
|
||||
}
|
||||
|
||||
// ---- ReseedFromGitHub ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReseedFromGitHubResponse_matches_the_reseed_report_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Reseed from GitHub complete",
|
||||
gather = new { deleted = 10, written = 12, orphanStatesGcd = 1, sunsetSkipped = 0 },
|
||||
analyze = new { deleted = 8, written = 8, orphanStatesGcd = 0, sunsetSkipped = 1 },
|
||||
ime = new { deleted = 79, written = 79 },
|
||||
cpeCommunityMappings = new { deleted = 5, written = 6 },
|
||||
cpeSeedMappings = new { deleted = 100, written = 100 }
|
||||
},
|
||||
new ReseedFromGitHubResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Reseed from GitHub complete",
|
||||
Gather = new ReseedRuleCountsNode { Deleted = 10, Written = 12, OrphanStatesGcd = 1, SunsetSkipped = 0 },
|
||||
Analyze = new ReseedRuleCountsNode { Deleted = 8, Written = 8, OrphanStatesGcd = 0, SunsetSkipped = 1 },
|
||||
Ime = new ReseedTableCountsNode { Deleted = 79, Written = 79 },
|
||||
CpeCommunityMappings = new ReseedTableCountsNode { Deleted = 5, Written = 6 },
|
||||
CpeSeedMappings = new ReseedTableCountsNode { Deleted = 100, Written = 100 }
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SubmitOffboardingFeedback -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SubmitOffboardingFeedback_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
// ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not
|
||||
// the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminCreatedResponse_pins_the_created_member_shape()
|
||||
{
|
||||
var newAdmin = new TenantAdminRow
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "operator@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "Operator",
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"{\"admin\":{"
|
||||
+ $"\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"operator@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:00:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"role\":\"Operator\","
|
||||
+ "\"canManageBootstrapTokens\":false}}",
|
||||
TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys()
|
||||
{
|
||||
// Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the
|
||||
// ITableEntity keys of the storage entity must never reappear on the wire.
|
||||
var row = new TenantAdminRow
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "legacy@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = null,
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(row);
|
||||
Assert.Equal(
|
||||
$"{{\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"legacy@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:05:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"canManageBootstrapTokens\":false}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
// ---- TenantGroupManagement -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TenantGroupListResponse_matches_the_groups_shape()
|
||||
{
|
||||
var groups = new List<TenantGroup>
|
||||
{
|
||||
new TenantGroup
|
||||
{
|
||||
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
|
||||
Name = "EU Customers",
|
||||
CreatedBy = "admin@contoso.com",
|
||||
CreatedAt = new DateTime(2026, 8, 27, 8, 0, 0, DateTimeKind.Utc),
|
||||
TenantIds = new List<string> { SampleTenantId },
|
||||
AssigneeCount = 1,
|
||||
Assignees = new List<TenantGroupAssignment>
|
||||
{
|
||||
new TenantGroupAssignment
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
|
||||
Role = "DelegatedReader",
|
||||
IsEnabled = true,
|
||||
AssignedBy = "admin@contoso.com",
|
||||
AssignedAt = new DateTime(2026, 8, 27, 8, 5, 0, DateTimeKind.Utc),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { groups },
|
||||
new TenantGroupListResponse { Groups = groups });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTenantGroupResponse_matches_the_created_group_shape()
|
||||
{
|
||||
var groupId = "c2c2c2c2-2222-4333-8444-555566667777";
|
||||
var name = "EU Customers";
|
||||
|
||||
AssertParity(
|
||||
new { groupId, name },
|
||||
new CreateTenantGroupResponse { GroupId = groupId, Name = name });
|
||||
}
|
||||
|
||||
// ---- VersionBlock --------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BlockedVersionListResponse_matches_the_rules_shape()
|
||||
{
|
||||
var rules = new List<BlockedVersionEntry>
|
||||
{
|
||||
new BlockedVersionEntry
|
||||
{
|
||||
VersionPattern = "1.4.*",
|
||||
Action = "Block",
|
||||
CreatedByEmail = "admin@contoso.com",
|
||||
CreatedAt = new DateTime(2026, 8, 30, 7, 0, 0, DateTimeKind.Utc),
|
||||
Reason = "Known crash loop",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new BlockedVersionListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockVersionResponse_matches_the_block_acknowledgement_shape()
|
||||
{
|
||||
var versionPattern = "1.4.*";
|
||||
var normalizedAction = "Kill";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
|
||||
versionPattern,
|
||||
action = normalizedAction
|
||||
},
|
||||
new BlockVersionResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
|
||||
VersionPattern = versionPattern,
|
||||
Action = normalizedAction
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockVersion_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var versionPattern = "1.4.*";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, message = $"Version pattern '{versionPattern}' unblocked." },
|
||||
new SuccessMessageResponse { Success = true, Message = $"Version pattern '{versionPattern}' unblocked." });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Annotations function folder (GetSessionAnnotations,
|
||||
/// ListSessionAnnotations, ListTenantSessionAnnotations, UpsertSessionAnnotation).
|
||||
/// The anonymous literals below are copied verbatim from the pre-migration call sites
|
||||
/// (including the item shapes built by AnnotationWire.ToWire/ToWireWithScope) and act as
|
||||
/// the independent fixture against the new typed DTOs.
|
||||
/// </summary>
|
||||
public class AnnotationsWireParityTests
|
||||
{
|
||||
private static readonly DateTime Created = new DateTime(2026, 8, 29, 9, 15, 42, DateTimeKind.Utc);
|
||||
private static readonly DateTime Updated = new DateTime(2026, 8, 30, 11, 22, 33, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void GetSessionAnnotations_success_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
lane = "operator",
|
||||
verdict = (string?)"root_cause_confirmed",
|
||||
note = (string?)"Driver package blocked ESP; matches APP-017.",
|
||||
authorUpn = "ops@contoso.com",
|
||||
authorDisplayName = "Contoso Ops",
|
||||
createdByUpn = "ops@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "APP-017", "NET-001" },
|
||||
},
|
||||
},
|
||||
writableLanes = new List<string> { "operator", "tenantadmin" },
|
||||
},
|
||||
new GetSessionAnnotationsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
Annotations = new List<SessionAnnotationItem>
|
||||
{
|
||||
new SessionAnnotationItem
|
||||
{
|
||||
Lane = "operator",
|
||||
Verdict = "root_cause_confirmed",
|
||||
Note = "Driver package blocked ESP; matches APP-017.",
|
||||
AuthorUpn = "ops@contoso.com",
|
||||
AuthorDisplayName = "Contoso Ops",
|
||||
CreatedByUpn = "ops@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "APP-017", "NET-001" },
|
||||
},
|
||||
},
|
||||
WritableLanes = new List<string> { "operator", "tenantadmin" },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionAnnotations_null_verdict_and_note_vanish_identically()
|
||||
{
|
||||
// A lane row can be verdict-only or note-only; null slots must drop their keys
|
||||
// on both sides (WhenWritingNull). Also covers the empty-annotations page.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
lane = "tenantadmin",
|
||||
verdict = (string?)null,
|
||||
note = (string?)null,
|
||||
authorUpn = "admin@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Admin",
|
||||
createdByUpn = "admin@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
writableLanes = new List<string>(),
|
||||
},
|
||||
new GetSessionAnnotationsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
Annotations = new List<SessionAnnotationItem>
|
||||
{
|
||||
new SessionAnnotationItem
|
||||
{
|
||||
Lane = "tenantadmin",
|
||||
Verdict = null,
|
||||
Note = null,
|
||||
AuthorUpn = "admin@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Admin",
|
||||
CreatedByUpn = "admin@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
WritableLanes = new List<string>(),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionAnnotationList_success_shape_is_wire_identical()
|
||||
{
|
||||
// Shared by ListSessionAnnotations (global) and ListTenantSessionAnnotations —
|
||||
// both sites emit { success, count, annotations(scoped), nextLink }.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
lane = "globaladmin",
|
||||
verdict = (string?)"analysis_wrong",
|
||||
note = (string?)"Rule fired on the wrong phase.",
|
||||
authorUpn = "ga@contoso.com",
|
||||
authorDisplayName = "Platform GA",
|
||||
createdByUpn = "ga@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "ESP-004" },
|
||||
},
|
||||
},
|
||||
nextLink = (string?)"/api/global/session-annotations?pageSize=50&continuation=abc123",
|
||||
},
|
||||
new SessionAnnotationListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Annotations = new List<SessionAnnotationScopedItem>
|
||||
{
|
||||
new SessionAnnotationScopedItem
|
||||
{
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
Lane = "globaladmin",
|
||||
Verdict = "analysis_wrong",
|
||||
Note = "Rule fired on the wrong phase.",
|
||||
AuthorUpn = "ga@contoso.com",
|
||||
AuthorDisplayName = "Platform GA",
|
||||
CreatedByUpn = "ga@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "ESP-004" },
|
||||
},
|
||||
},
|
||||
NextLink = "/api/global/session-annotations?pageSize=50&continuation=abc123",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionAnnotationList_null_nextLink_and_null_item_slots_vanish_identically()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
lane = "operator",
|
||||
verdict = (string?)null,
|
||||
note = (string?)"Note-only annotation.",
|
||||
authorUpn = "ops@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Ops",
|
||||
createdByUpn = "ops@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
nextLink = (string?)null,
|
||||
},
|
||||
new SessionAnnotationListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Annotations = new List<SessionAnnotationScopedItem>
|
||||
{
|
||||
new SessionAnnotationScopedItem
|
||||
{
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
Lane = "operator",
|
||||
Verdict = null,
|
||||
Note = "Note-only annotation.",
|
||||
AuthorUpn = "ops@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Ops",
|
||||
CreatedByUpn = "ops@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_deleted_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, deleted = true },
|
||||
new UpsertSessionAnnotationDeletedResponse { Success = true, Deleted = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_success_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
annotation = (object)new
|
||||
{
|
||||
lane = "operator",
|
||||
verdict = (string?)"different_problem",
|
||||
note = (string?)"Actually a proxy issue, not the app.",
|
||||
authorUpn = "ops@contoso.com",
|
||||
authorDisplayName = "Contoso Ops",
|
||||
createdByUpn = "admin@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "NET-001" },
|
||||
},
|
||||
},
|
||||
new UpsertSessionAnnotationResponse
|
||||
{
|
||||
Success = true,
|
||||
Annotation = new SessionAnnotationItem
|
||||
{
|
||||
Lane = "operator",
|
||||
Verdict = "different_problem",
|
||||
Note = "Actually a proxy issue, not the app.",
|
||||
AuthorUpn = "ops@contoso.com",
|
||||
AuthorDisplayName = "Contoso Ops",
|
||||
CreatedByUpn = "admin@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "NET-001" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_verdict_only_null_note_vanishes_identically()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
annotation = (object)new
|
||||
{
|
||||
lane = "tenantadmin",
|
||||
verdict = (string?)"inconclusive",
|
||||
note = (string?)null,
|
||||
authorUpn = "admin@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Admin",
|
||||
createdByUpn = "admin@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Created,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
new UpsertSessionAnnotationResponse
|
||||
{
|
||||
Success = true,
|
||||
Annotation = new SessionAnnotationItem
|
||||
{
|
||||
Lane = "tenantadmin",
|
||||
Verdict = "inconclusive",
|
||||
Note = null,
|
||||
AuthorUpn = "admin@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Admin",
|
||||
CreatedByUpn = "admin@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Created,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the anonymous-object → typed-DTO migration: for every converted
|
||||
/// call site there is a case here serializing the OLD anonymous literal (copied from the diff)
|
||||
/// and the NEW DTO with the same values, comparing the JSON strings ordinally — key names,
|
||||
/// key ORDER and presence/absence (WhenWritingNull) all must match, because MCP hands raw
|
||||
/// response JSON to an LLM. Each site gets a populated case and, where a slot can be null at
|
||||
/// runtime, a null-slot case (the key must vanish identically on both sides).
|
||||
/// </summary>
|
||||
public class ApiResponseWireParityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
|
||||
|
||||
/// <summary>
|
||||
/// Serialize both shapes with the production wire options and compare ordinally.
|
||||
/// The typed side serializes as its runtime type — serializing as IApiResponse would
|
||||
/// emit no properties at all.
|
||||
/// </summary>
|
||||
internal static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
|
||||
{
|
||||
var expected = JsonSerializer.Serialize(anonymousLiteral, anonymousLiteral.GetType(), WireOptions);
|
||||
var actual = JsonSerializer.Serialize(typed, typed.GetType(), WireOptions);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new { success = true, message = "Rule created" },
|
||||
new SuccessMessageResponse { Success = true, Message = "Rule created" });
|
||||
|
||||
AssertWireIdentical(
|
||||
new { success = false, message = "Failed to create rule" },
|
||||
new SuccessMessageResponse { Success = false, Message = "Failed to create rule" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessOnlyResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -79,13 +79,7 @@ public class AppHomingServiceTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new AppHomingService(
|
||||
|
||||
+4
-4
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", status: "Failed", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
|
||||
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
|
||||
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var alert = Alert();
|
||||
// The Functions host serializes camelCase on the wire; mirror that here so the
|
||||
// TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
|
||||
// asserted property names match what the web client actually parses.
|
||||
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
|
||||
// Empty-data early return still surfaces the block (episodes can outlive window data).
|
||||
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
||||
|
||||
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
A("InProgress"),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
{
|
||||
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
|
||||
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal("stable", app.GetProperty("trend").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
|
||||
Assert.False(app.TryGetProperty("trendDelta", out _));
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
return JsonSerializer.SerializeToElement(result);
|
||||
return TestWire.SerializeToElement(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
|
||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
var app = root.GetProperty("apps")[0];
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
private static JsonElement Build(int? pageSize, int skip = 0) =>
|
||||
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
|
||||
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
|
||||
|
||||
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageSize_LastPage_HasNullNextLink()
|
||||
public void PageSize_LastPage_OmitsNextLink()
|
||||
{
|
||||
var root = Build(pageSize: 2, skip: 4);
|
||||
|
||||
Assert.Equal(1, root.GetProperty("count").GetInt32());
|
||||
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
|
||||
Assert.False(root.TryGetProperty("nextLink", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
@@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
|
||||
@@ -471,7 +471,8 @@ public class AuthFunctionTests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Converts anonymous object to dynamic for property access in tests.
|
||||
/// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names
|
||||
/// (camelCase), so the asserts read the same casing a client would.
|
||||
/// </summary>
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
@@ -479,7 +480,7 @@ public class AuthFunctionTests
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -6,31 +6,44 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the CMTrace time-skew regression tripwire (CmTraceSkewTripwire.Evaluate): fires only
|
||||
/// when the IME-vs-other median delta divergence sits on the 15-minute offset grid (residual
|
||||
/// < 2 min) AND both sides have enough samples over enough distinct upload batches. The
|
||||
/// tripwire's goal state is to never fire, so the must-stay-silent directions are the primary
|
||||
/// cases: off-grid replays, sub-grid divergence, thin samples, batch-composition artifacts,
|
||||
/// and common-mode spool latency must all pass without a hit.
|
||||
/// < 2 min) AND both sides have enough samples over enough distinct upload batches, judged
|
||||
/// over the session's most recent ingest era. The tripwire's goal state is to never fire, so
|
||||
/// the must-stay-silent directions are the primary cases: off-grid replays, sub-grid
|
||||
/// divergence, thin samples, batch-composition artifacts, common-mode spool latency, and the
|
||||
/// skewed-but-ancient leg of a resumed pre-provisioning session must all pass without a hit.
|
||||
/// </summary>
|
||||
public class CmTraceSkewTripwireTests
|
||||
{
|
||||
/// <summary>Reference ingest era used by every single-era scan below.</summary>
|
||||
private static readonly DateTime EraBase = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Scan with the given per-side medians, comfortably above all sample/batch minimums.</summary>
|
||||
private static SessionSkewScan MakeScan(
|
||||
double imeDeltaMinutes, double otherDeltaMinutes,
|
||||
int imeSamples = 30, int otherSamples = 30,
|
||||
int imeBatches = 5, int otherBatches = 5)
|
||||
{
|
||||
var scan = new SessionSkewScan
|
||||
{
|
||||
ImeDistinctBatchCount = imeBatches,
|
||||
OtherDistinctBatchCount = otherBatches,
|
||||
};
|
||||
for (int i = 0; i < imeSamples; i++)
|
||||
scan.ImeDeltaMinutes.Add(imeDeltaMinutes);
|
||||
for (int i = 0; i < otherSamples; i++)
|
||||
scan.OtherDeltaMinutes.Add(otherDeltaMinutes);
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, imeDeltaMinutes, imeSamples, imeBatches, EraBase);
|
||||
AddSamples(scan, isIme: false, otherDeltaMinutes, otherSamples, otherBatches, EraBase);
|
||||
return scan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="count"/> identical samples spread round-robin over
|
||||
/// <paramref name="batches"/> ReceivedAt stamps one minute apart, starting at
|
||||
/// <paramref name="eraStart"/> — one continuous ingest era.
|
||||
/// </summary>
|
||||
private static void AddSamples(
|
||||
SessionSkewScan scan, bool isIme, double deltaMinutes, int count, int batches, DateTime eraStart)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
scan.Add(isIme, deltaMinutes, eraStart.AddMinutes(i % Math.Max(batches, 1)));
|
||||
}
|
||||
|
||||
private static List<double> ImeDeltas(SessionSkewScan scan)
|
||||
=> scan.ImeSamples.Select(s => s.DeltaMinutes).ToList();
|
||||
|
||||
// ── Must fire ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -106,6 +119,7 @@ public class CmTraceSkewTripwireTests
|
||||
{
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(null));
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(new SessionSkewScan()));
|
||||
Assert.Null(CmTraceSkewTripwire.ResolveEraStartUtc(new SessionSkewScan()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -114,8 +128,8 @@ public class CmTraceSkewTripwireTests
|
||||
// A handful of replayed old lines (huge off-grid deltas) inside an otherwise healthy
|
||||
// IME stream must not drag the median onto the grid.
|
||||
var scan = MakeScan(imeDeltaMinutes: 0.5, otherDeltaMinutes: 0.4);
|
||||
scan.ImeDeltaMinutes.Add(-1327.0);
|
||||
scan.ImeDeltaMinutes.Add(-738.0);
|
||||
scan.Add(isIme: true, -1327.0, EraBase);
|
||||
scan.Add(isIme: true, -738.0, EraBase);
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
}
|
||||
|
||||
@@ -126,21 +140,23 @@ public class CmTraceSkewTripwireTests
|
||||
// re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages
|
||||
// (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance.
|
||||
// The other side was live. The per-sample grid-conformity guard must keep this silent.
|
||||
// The burst arrives in ONE batch, inside the same era as the live traffic.
|
||||
var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80);
|
||||
var burstReceivedAt = EraBase.AddMinutes(80);
|
||||
void AddRamp(double from, double to, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
scan.ImeDeltaMinutes.Add(from + i * (to - from) / count);
|
||||
scan.Add(isIme: true, from + i * (to - from) / count, burstReceivedAt);
|
||||
}
|
||||
AddRamp(46.0, 88.0, 125);
|
||||
AddRamp(88.0, 100.0, 250);
|
||||
AddRamp(100.0, 733.0, 125);
|
||||
|
||||
var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2);
|
||||
var medianIme = ImeDeltas(scan).OrderBy(v => v).ElementAt(scan.ImeSamples.Count / 2);
|
||||
Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15
|
||||
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5);
|
||||
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(ImeDeltas(scan), 0.1) < 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,7 +166,7 @@ public class CmTraceSkewTripwireTests
|
||||
// zone. Every line is individually on the grid, so the conformity guard must NOT mask it.
|
||||
var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65);
|
||||
for (int i = 0; i < 35; i++)
|
||||
scan.ImeDeltaMinutes.Add(120.0);
|
||||
scan.Add(isIme: true, 120.0, EraBase);
|
||||
|
||||
var result = CmTraceSkewTripwire.Evaluate(scan);
|
||||
Assert.NotNull(result);
|
||||
@@ -166,6 +182,105 @@ public class CmTraceSkewTripwireTests
|
||||
Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List<double>(), 0.0));
|
||||
}
|
||||
|
||||
// ── Ingest-era window ──────────────────────────────────────────────────
|
||||
|
||||
private static readonly DateTime PartOneLeg = new DateTime(2026, 8, 20, 8, 20, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime PartTwoLeg = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Silent_OnPreProvisioningHandoverWhereOnlyTheOldLegWasSkewed()
|
||||
{
|
||||
// Field 2026-09-01, three sessions of one tenant (e797117b / c06d639d / d7c8032b):
|
||||
// Part 1 ran on 2026-08-20 under agent 2.0.1409 — before per-line anchoring — and left
|
||||
// 26 IME samples at exactly −60 min; Part 2 ran 12 days later on 2.0.1445 and is clean.
|
||||
// The stale leg outnumbers the fresh one 26:9 and is perfectly grid-conformant, so
|
||||
// nothing but the era window can separate them (event rows carry no agent version).
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: true, -0.16, count: 9, batches: 6, eraStart: PartTwoLeg);
|
||||
AddSamples(scan, isIme: false, -0.26, count: 95, batches: 40, eraStart: PartTwoLeg);
|
||||
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
Assert.Equal(PartTwoLeg, CmTraceSkewTripwire.ResolveEraStartUtc(scan));
|
||||
|
||||
// …and the old leg really is what used to fire: judged on its own it still does.
|
||||
var oldLegOnly = new SessionSkewScan();
|
||||
AddSamples(oldLegOnly, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
|
||||
AddSamples(oldLegOnly, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
|
||||
var legacy = CmTraceSkewTripwire.Evaluate(oldLegOnly);
|
||||
Assert.NotNull(legacy);
|
||||
Assert.Equal(-4, legacy!.GridSteps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fires_WhenTheCurrentEraIsSkewed_AndReportsWhatTheOlderLegContributed()
|
||||
{
|
||||
// The window must not become a blanket mute for resumed sessions: a skew in the leg the
|
||||
// running agent just produced still fires, and the report says how much history it left
|
||||
// out so an operator can tell a fresh regression from an inherited one.
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: false, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: true, -60.0, count: 25, batches: 8, eraStart: PartTwoLeg);
|
||||
AddSamples(scan, isIme: false, 0.0, count: 40, batches: 12, eraStart: PartTwoLeg);
|
||||
|
||||
var result = CmTraceSkewTripwire.Evaluate(scan);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(-4, result!.GridSteps);
|
||||
Assert.Equal(-60.0, result.DiffMinutes, 3);
|
||||
Assert.Equal(25, result.ImeSampleCount);
|
||||
Assert.Equal(40, result.OtherSampleCount);
|
||||
Assert.Equal(8, result.ImeBatchCount);
|
||||
Assert.Equal(12, result.OtherBatchCount);
|
||||
Assert.Equal(PartTwoLeg, result.EraStartUtc);
|
||||
Assert.Equal(30, result.ImeSamplesOutsideEra);
|
||||
Assert.Equal(30, result.OtherSamplesOutsideEra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EraWindow_SplitsOnGapsWiderThanTheThreshold_NotOnTotalSpan()
|
||||
{
|
||||
// A long leg that keeps uploading is ONE era even when it spans far more than the gap
|
||||
// threshold end to end — the boundary is the gap between consecutive batches.
|
||||
var start = PartTwoLeg;
|
||||
var continuous = new SessionSkewScan();
|
||||
for (int i = 0; i < 8; i++)
|
||||
continuous.Add(isIme: false, 0.0, start.AddMinutes(30 * i)); // 3.5 h span, 30 min gaps
|
||||
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(continuous));
|
||||
|
||||
// Exactly at the threshold is still one era (the split needs a STRICTLY wider gap).
|
||||
var atThreshold = new SessionSkewScan();
|
||||
atThreshold.Add(isIme: false, 0.0, start);
|
||||
atThreshold.Add(isIme: false, 0.0, start.AddHours(CmTraceSkewTripwire.EraGapHours));
|
||||
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(atThreshold));
|
||||
|
||||
// One silence past the threshold ends the era, and the boundary is found across BOTH
|
||||
// sides — an IME-only batch after the gap must still open the new era.
|
||||
var split = new SessionSkewScan();
|
||||
split.Add(isIme: false, 0.0, start);
|
||||
split.Add(isIme: true, 0.0, start.AddHours(3));
|
||||
Assert.Equal(start.AddHours(3), CmTraceSkewTripwire.ResolveEraStartUtc(split));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleCap_KeepsTheNewestSamples()
|
||||
{
|
||||
// The cap is a memory backstop for pathological sessions. Since the era window judges
|
||||
// the NEWEST leg, the buffer must drop the oldest entries — keeping the head instead
|
||||
// would blind the detector on exactly the sessions big enough to reach the cap.
|
||||
var scan = new SessionSkewScan();
|
||||
int adds = SessionSkewScan.MaxSamplesPerSide + 5_000;
|
||||
for (int i = 0; i < adds; i++)
|
||||
scan.Add(isIme: true, i, EraBase.AddSeconds(i));
|
||||
|
||||
Assert.True(scan.ImeSamples.Count >= SessionSkewScan.MaxSamplesPerSide);
|
||||
Assert.True(scan.ImeSamples.Count < SessionSkewScan.MaxSamplesPerSide * 2);
|
||||
Assert.Equal((double)(adds - 1), scan.ImeSamples[scan.ImeSamples.Count - 1].DeltaMinutes);
|
||||
Assert.True(scan.ImeSamples[0].DeltaMinutes > 0); // the oldest adds were trimmed away
|
||||
}
|
||||
|
||||
// ── Bias suppression predicate ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,12 +30,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Config function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class ConfigWireParityTests
|
||||
{
|
||||
// ---- GetAllTenantConfigurations (delegated one-shot page + GA paginated mode) --------
|
||||
|
||||
[Fact]
|
||||
public void GetAllTenantConfigurationsResponse_matches_the_paginated_projection_shape()
|
||||
{
|
||||
// TenantConfigProjection.ProjectAll returns List<Dictionary<string, object?>>.
|
||||
var tenants = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["tenantId"] = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
["domainName"] = "contoso.com",
|
||||
["planTier"] = "pro",
|
||||
},
|
||||
};
|
||||
string? nextLink = "/api/config/all?pageSize=1&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = tenants.Count,
|
||||
tenants,
|
||||
nextLink,
|
||||
},
|
||||
new GetAllTenantConfigurationsResponse
|
||||
{
|
||||
Count = tenants.Count,
|
||||
Tenants = tenants,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllTenantConfigurationsResponse_omits_the_null_nextLink_of_the_delegated_one_shot_page()
|
||||
{
|
||||
var projected = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["tenantId"] = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e",
|
||||
["domainName"] = "fabrikam.com",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = projected.Count,
|
||||
tenants = projected,
|
||||
nextLink = (string?)null,
|
||||
},
|
||||
new GetAllTenantConfigurationsResponse
|
||||
{
|
||||
Count = projected.Count,
|
||||
Tenants = projected,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateTenantAppHoming -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantAppHomingResponse_matches_the_flip_result_shape()
|
||||
{
|
||||
var changed = true;
|
||||
string? homedAppClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
|
||||
string? lastAuthClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
|
||||
DateTime? lastAuthClientIdSince = new DateTime(2026, 8, 29, 6, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
changed,
|
||||
homedApp = "primary",
|
||||
homedAppClientId,
|
||||
lastAuthClientId,
|
||||
lastAuthClientIdSince,
|
||||
// Old ProbePayload helper built this anonymous shape.
|
||||
probe = new
|
||||
{
|
||||
attempted = true,
|
||||
succeeded = true,
|
||||
isTransient = false,
|
||||
},
|
||||
},
|
||||
new UpdateTenantAppHomingResponse
|
||||
{
|
||||
Success = true,
|
||||
Changed = changed,
|
||||
HomedApp = "primary",
|
||||
HomedAppClientId = homedAppClientId,
|
||||
LastAuthClientId = lastAuthClientId,
|
||||
LastAuthClientIdSince = lastAuthClientIdSince,
|
||||
Probe = new AppHomingProbeWire
|
||||
{
|
||||
Attempted = true,
|
||||
Succeeded = true,
|
||||
IsTransient = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantAppHomingResponse_omits_null_homing_slots()
|
||||
{
|
||||
// Legacy-homed tenant that never authenticated: pin and last-auth fields are null.
|
||||
string? homedAppClientId = null;
|
||||
string? lastAuthClientId = null;
|
||||
DateTime? lastAuthClientIdSince = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
changed = false,
|
||||
homedApp = "legacy",
|
||||
homedAppClientId,
|
||||
lastAuthClientId,
|
||||
lastAuthClientIdSince,
|
||||
probe = new
|
||||
{
|
||||
attempted = false,
|
||||
succeeded = false,
|
||||
isTransient = false,
|
||||
},
|
||||
},
|
||||
new UpdateTenantAppHomingResponse
|
||||
{
|
||||
Success = true,
|
||||
Changed = false,
|
||||
HomedApp = "legacy",
|
||||
HomedAppClientId = null,
|
||||
LastAuthClientId = null,
|
||||
LastAuthClientIdSince = null,
|
||||
Probe = new AppHomingProbeWire
|
||||
{
|
||||
Attempted = false,
|
||||
Succeeded = false,
|
||||
IsTransient = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetLatestVersions ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetLatestVersionsResponse_matches_the_versions_shape()
|
||||
{
|
||||
string? agentVersion = "2.14.3.0";
|
||||
string? bootstrapVersion = "1.9";
|
||||
string? sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
DateTimeOffset? fetchedAtUtc = new DateTimeOffset(2026, 8, 30, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
latestAgentVersion = agentVersion,
|
||||
latestBootstrapScriptVersion = bootstrapVersion,
|
||||
latestAgentSha256 = sha256,
|
||||
fetchedAtUtc,
|
||||
source = "cache"
|
||||
},
|
||||
new GetLatestVersionsResponse
|
||||
{
|
||||
LatestAgentVersion = agentVersion,
|
||||
LatestBootstrapScriptVersion = bootstrapVersion,
|
||||
LatestAgentSha256 = sha256,
|
||||
FetchedAtUtc = fetchedAtUtc,
|
||||
Source = "cache"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLatestVersionsResponse_omits_all_null_slots_when_the_version_blob_was_unavailable()
|
||||
{
|
||||
// versions == null at the site: every ?. projection is null, source falls back to "blob".
|
||||
string? agentVersion = null;
|
||||
string? bootstrapVersion = null;
|
||||
string? sha256 = null;
|
||||
DateTimeOffset? fetchedAtUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
latestAgentVersion = agentVersion,
|
||||
latestBootstrapScriptVersion = bootstrapVersion,
|
||||
latestAgentSha256 = sha256,
|
||||
fetchedAtUtc,
|
||||
source = "blob"
|
||||
},
|
||||
new GetLatestVersionsResponse
|
||||
{
|
||||
LatestAgentVersion = null,
|
||||
LatestBootstrapScriptVersion = null,
|
||||
LatestAgentSha256 = null,
|
||||
FetchedAtUtc = null,
|
||||
Source = "blob"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantConfigFieldsSchema -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantConfigFieldsSchemaResponse_matches_the_schema_shape()
|
||||
{
|
||||
IReadOnlyList<TenantConfigFieldSchema> schema = new List<TenantConfigFieldSchema>
|
||||
{
|
||||
new TenantConfigFieldSchema(
|
||||
name: "contactEmail",
|
||||
type: "string",
|
||||
format: "email",
|
||||
nullable: true,
|
||||
writable: true,
|
||||
reason: null,
|
||||
gaOnly: false,
|
||||
revertProtected: false),
|
||||
new TenantConfigFieldSchema(
|
||||
name: "homedAppClientId",
|
||||
type: "string",
|
||||
format: null,
|
||||
nullable: true,
|
||||
writable: false,
|
||||
reason: "system-owned",
|
||||
gaOnly: true,
|
||||
revertProtected: true),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = schema.Count,
|
||||
writableCount = schema.Count(f => f.Writable),
|
||||
fields = schema,
|
||||
},
|
||||
new GetTenantConfigFieldsSchemaResponse
|
||||
{
|
||||
Count = schema.Count,
|
||||
WritableCount = schema.Count(f => f.Writable),
|
||||
Fields = schema,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ListTenantConfigBackups ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ListTenantConfigBackupsResponse_matches_the_backup_listing_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var backupId = "2519968812345678901-a1b2c3d4";
|
||||
var backupTakenAt = new DateTime(2026, 8, 30, 9, 45, 0, DateTimeKind.Utc);
|
||||
var changedBy = "admin@contoso.com";
|
||||
var source = "mcp-patch";
|
||||
string? reason = "enable webhook notifications";
|
||||
Dictionary<string, string>? diff = new Dictionary<string, string>
|
||||
{
|
||||
["WebhookUrl"] = "(none) -> https://***MASKED***",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
backups = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
backupId,
|
||||
backupTakenAt,
|
||||
changedBy,
|
||||
source,
|
||||
reason,
|
||||
diff,
|
||||
},
|
||||
},
|
||||
},
|
||||
new ListTenantConfigBackupsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Backups = new List<TenantConfigBackupItem>
|
||||
{
|
||||
new TenantConfigBackupItem
|
||||
{
|
||||
BackupId = backupId,
|
||||
BackupTakenAt = backupTakenAt,
|
||||
ChangedBy = changedBy,
|
||||
Source = source,
|
||||
Reason = reason,
|
||||
Diff = diff,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListTenantConfigBackupsResponse_omits_null_reason_and_diff()
|
||||
{
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
var backupId = "2519968800000000000-e5f6a7b8";
|
||||
var backupTakenAt = new DateTime(2026, 8, 29, 22, 10, 0, DateTimeKind.Utc);
|
||||
string? reason = null;
|
||||
Dictionary<string, string>? diff = null; // unparseable stored DiffJson → TryParseDiff returns null
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
backups = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
backupId,
|
||||
backupTakenAt,
|
||||
changedBy = "system",
|
||||
source = "portal-put",
|
||||
reason,
|
||||
diff,
|
||||
},
|
||||
},
|
||||
},
|
||||
new ListTenantConfigBackupsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Backups = new List<TenantConfigBackupItem>
|
||||
{
|
||||
new TenantConfigBackupItem
|
||||
{
|
||||
BackupId = backupId,
|
||||
BackupTakenAt = backupTakenAt,
|
||||
ChangedBy = "system",
|
||||
Source = "portal-put",
|
||||
Reason = null,
|
||||
Diff = null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- PatchTenantConfigurationFields / RevertTenantConfiguration (shared WriteOutcome) -
|
||||
|
||||
[Fact]
|
||||
public void TenantConfigPatchOutcomeResponse_matches_the_applied_patch_shape()
|
||||
{
|
||||
IReadOnlyCollection<string> appliedFields = new List<string> { "ContactEmail", "NotificationsEnabled" };
|
||||
Dictionary<string, string>? diff = new Dictionary<string, string>
|
||||
{
|
||||
["ContactEmail"] = "(none) -> it@contoso.com",
|
||||
["NotificationsEnabled"] = "False -> True",
|
||||
};
|
||||
string? backupId = "2519968812345678901-a1b2c3d4";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
appliedFields,
|
||||
diff,
|
||||
backupId,
|
||||
noOp = appliedFields.Count == 0,
|
||||
},
|
||||
new TenantConfigPatchOutcomeResponse
|
||||
{
|
||||
Success = true,
|
||||
AppliedFields = appliedFields,
|
||||
Diff = diff,
|
||||
BackupId = backupId,
|
||||
NoOp = appliedFields.Count == 0,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantConfigPatchOutcomeResponse_omits_null_diff_and_backupId_on_a_noop()
|
||||
{
|
||||
IReadOnlyCollection<string> appliedFields = new List<string>();
|
||||
Dictionary<string, string>? diff = null;
|
||||
string? backupId = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
appliedFields,
|
||||
diff,
|
||||
backupId,
|
||||
noOp = appliedFields.Count == 0,
|
||||
},
|
||||
new TenantConfigPatchOutcomeResponse
|
||||
{
|
||||
Success = true,
|
||||
AppliedFields = appliedFields,
|
||||
Diff = null,
|
||||
BackupId = null,
|
||||
NoOp = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SetTenantPlanTier ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetTenantPlanTierResponse_matches_the_plan_state_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
DateTime? trialExpiresUtc = new DateTime(2026, 9, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? retentionGraceEndsUtc = new DateTime(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
planTier = "pro",
|
||||
trialExpiresUtc,
|
||||
trialConsumed = true,
|
||||
effectiveEdition = "pro",
|
||||
retentionGraceEndsUtc
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanTier = "pro",
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = true,
|
||||
EffectiveEdition = "pro",
|
||||
RetentionGraceEndsUtc = retentionGraceEndsUtc
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTenantPlanTierResponse_omits_null_trial_and_grace_slots()
|
||||
{
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
DateTime? trialExpiresUtc = null;
|
||||
DateTime? retentionGraceEndsUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
planTier = "community",
|
||||
trialExpiresUtc,
|
||||
trialConsumed = false,
|
||||
effectiveEdition = "community",
|
||||
retentionGraceEndsUtc
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanTier = "community",
|
||||
TrialExpiresUtc = null,
|
||||
TrialConsumed = false,
|
||||
EffectiveEdition = "community",
|
||||
RetentionGraceEndsUtc = null
|
||||
});
|
||||
}
|
||||
|
||||
// ---- StartTenantTrial ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void StartTenantTrialResponse_matches_the_trial_start_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
DateTime? trialStartedUtc = new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? trialExpiresUtc = trialStartedUtc.Value.AddDays(30);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
trialStartedUtc,
|
||||
trialExpiresUtc,
|
||||
effectiveEdition = "pro"
|
||||
},
|
||||
new StartTenantTrialResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TrialStartedUtc = trialStartedUtc,
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
EffectiveEdition = "pro"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartTenantTrialResponse_omits_null_trial_slots()
|
||||
{
|
||||
// The site always sets both dates before responding — this pins that a null slot
|
||||
// would vanish identically on both sides anyway (compile-time type is DateTime?).
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
DateTime? trialStartedUtc = null;
|
||||
DateTime? trialExpiresUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
trialStartedUtc,
|
||||
trialExpiresUtc,
|
||||
effectiveEdition = "pro"
|
||||
},
|
||||
new StartTenantTrialResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TrialStartedUtc = null,
|
||||
TrialExpiresUtc = null,
|
||||
EffectiveEdition = "pro"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetPlanTierDefinitions / SetPlanTierDefinitions ---------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PlanTierDefinitionsResponse_matches_the_tiers_shape()
|
||||
{
|
||||
var tiers = new List<PlanTierDefinition>
|
||||
{
|
||||
new PlanTierDefinition
|
||||
{
|
||||
Name = "community",
|
||||
DailyRequestLimit = 100,
|
||||
MonthlyRequestLimit = 3000,
|
||||
Description = "Free tier",
|
||||
},
|
||||
new PlanTierDefinition
|
||||
{
|
||||
Name = "pro",
|
||||
DailyRequestLimit = 1000,
|
||||
MonthlyRequestLimit = 30000,
|
||||
Description = "Paid tier",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tiers },
|
||||
new PlanTierDefinitionsResponse { Tiers = tiers });
|
||||
}
|
||||
|
||||
// ---- TestWebhookNotification (dual-purpose: success carries the delivery verdict) ----
|
||||
|
||||
[Fact]
|
||||
public void TestWebhookNotificationResponse_matches_the_delivery_verdict_shape()
|
||||
{
|
||||
int? statusCode = 200;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
statusCode,
|
||||
message = "Test notification sent successfully"
|
||||
},
|
||||
new TestWebhookNotificationResponse
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = statusCode,
|
||||
Message = "Test notification sent successfully"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestWebhookNotificationResponse_omits_a_null_statusCode_when_the_send_got_no_response()
|
||||
{
|
||||
int? statusCode = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = false,
|
||||
statusCode,
|
||||
message = "The webhook endpoint could not be reached."
|
||||
},
|
||||
new TestWebhookNotificationResponse
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = null,
|
||||
Message = "The webhook endpoint could not be reached."
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateAdminConfiguration --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateAdminConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
PartitionKey = "GlobalConfig",
|
||||
RowKey = "config",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Admin configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Admin configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
||||
|
||||
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
|
||||
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
|
||||
private static void AssertProperty(object body, string propertyName, object? expected)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
Assert.Equal(expected, prop!.GetValue(body));
|
||||
}
|
||||
|
||||
private static T GetProperty<T>(object body, string propertyName)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
var value = prop!.GetValue(body);
|
||||
return (T)value!;
|
||||
|
||||
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
|
||||
},
|
||||
};
|
||||
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
|
||||
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
|
||||
|
||||
Assert.Equal(3, totals.CompletedJourneys);
|
||||
Assert.Equal(2, totals.FirstTimeRight);
|
||||
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[Fact]
|
||||
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
||||
{
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
||||
});
|
||||
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
|
||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||
// days=1 summed yesterday AND today.
|
||||
var today = new DateTime(2026, 7, 27);
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
|
||||
}
|
||||
|
||||
private static DeviceHistory RepeaterHistory(
|
||||
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
||||
};
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(2, selected.Count);
|
||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
||||
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
|
||||
}
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(10, selected.Count);
|
||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||
|
||||
+5
-2
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -43,10 +44,12 @@ public class GatherRulesFunctionAuthorEndpointTests
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
|
||||
{
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices.
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices — use the production
|
||||
// wire settings so assertions run against the shape the deployed worker serializes.
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
+20
-30
@@ -60,8 +60,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,10 +91,10 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("Lenovo", (string)item.manufacturer);
|
||||
Assert.Equal("ThinkPad T14", (string)item.model);
|
||||
Assert.Equal("SN1", (string)item.serialNumber);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("Lenovo", (string)item.Manufacturer);
|
||||
Assert.Equal("ThinkPad T14", (string)item.Model);
|
||||
Assert.Equal("SN1", (string)item.SerialNumber);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -117,9 +117,9 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,12 +135,12 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -160,8 +160,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -182,8 +182,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.True((bool)item.isCloudPc);
|
||||
var item = aggregated[0];
|
||||
Assert.True((bool)item.IsCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -196,8 +196,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.False((bool)item.isCloudPc);
|
||||
var item = aggregated[0];
|
||||
Assert.False((bool)item.IsCloudPc);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -219,14 +219,4 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
IsCloudPc = isCloudPc,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,8 +77,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
Assert.Equal(4, totalRawReports);
|
||||
Assert.Equal(2, aggregated.Count);
|
||||
|
||||
var dell = ToDynamic(aggregated.First(a => ((string)ToDynamic(a).manufacturer).Equals("Dell", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.Equal(3, (int)dell.attemptCount);
|
||||
var dell = aggregated.First(a => a.Manufacturer.Equals("Dell", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(3, (int)dell.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,9 +94,9 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(2, (int)item.uniqueSerials);
|
||||
Assert.Equal(4, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(2, (int)item.UniqueSerials);
|
||||
Assert.Equal(4, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,10 +108,10 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
var samples = (IEnumerable<object>)item.sampleSerialNumbers;
|
||||
var item = aggregated[0];
|
||||
var samples = (IEnumerable<object>)item.SampleSerialNumbers;
|
||||
Assert.Equal(5, samples.Count());
|
||||
Assert.Equal(8, (int)item.uniqueSerials);
|
||||
Assert.Equal(8, (int)item.UniqueSerials);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -134,9 +134,9 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -152,12 +152,12 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -176,10 +176,10 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("", (string)item.manufacturer);
|
||||
Assert.Equal("", (string)item.model);
|
||||
Assert.Equal(2, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("", (string)item.Manufacturer);
|
||||
Assert.Equal("", (string)item.Model);
|
||||
Assert.Equal(2, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -199,14 +199,4 @@ public class GetHardwareRejectedFunctionTests
|
||||
AgentTimestamp = ingestedAt,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
private static JsonElement Serialize(TenantConfiguration config)
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var json = TestWire.Serialize(payload);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
"trialExpiresUtc",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
// wire options omit null keys (WhenWritingNull) — exactly what production always
|
||||
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
|
||||
"unrestrictedMode",
|
||||
"validateAutopilotDevice",
|
||||
}, fieldNames);
|
||||
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
|
||||
@@ -60,8 +60,8 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,10 +91,10 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("Lenovo", (string)item.manufacturer);
|
||||
Assert.Equal("ThinkCentre M720q", (string)item.model);
|
||||
Assert.Equal("SN1", (string)item.serialNumber);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("Lenovo", (string)item.Manufacturer);
|
||||
Assert.Equal("ThinkCentre M720q", (string)item.Model);
|
||||
Assert.Equal("SN1", (string)item.SerialNumber);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -117,9 +117,9 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,12 +135,12 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -160,8 +160,8 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -181,14 +181,4 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
AgentTimestamp = ingestedAt,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Graph function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class GraphWireParityTests
|
||||
{
|
||||
// ---- GetScriptDisplayNames -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_matches_the_resolved_shape_with_malformed_tokens()
|
||||
{
|
||||
// Unresolved refs keep their key with an explicit null VALUE — dictionary values are
|
||||
// not subject to WhenWritingNull (only properties are), on both sides.
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001"] = "Install VPN client",
|
||||
["Remediation:2eab4b63-5d2f-4c8b-8b12-52f3aaaa1002"] = null,
|
||||
};
|
||||
var malformed = new List<string> { "NotARef", "Platform:" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
refs = payload,
|
||||
malformed = malformed.Count > 0 ? (object)malformed : null,
|
||||
},
|
||||
new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_omits_malformed_when_all_tokens_parsed()
|
||||
{
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:3fbc5c74-6e30-4d9c-9c23-52f3aaaa1003"] = "Set timezone",
|
||||
};
|
||||
var malformed = new List<string>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
refs = payload,
|
||||
malformed = malformed.Count > 0 ? (object)malformed : null,
|
||||
},
|
||||
new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed.Count > 0 ? malformed : null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_matches_the_empty_body_early_exit_shape()
|
||||
{
|
||||
// Old early-exit sites (empty body / empty refs list) never declared a malformed key
|
||||
// at all: `new { refs = new Dictionary<string, string?>() }`. The typed side leaves
|
||||
// Malformed null so WhenWritingNull removes the key identically.
|
||||
AssertParity(
|
||||
new { refs = new Dictionary<string, string?>() },
|
||||
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
|
||||
{
|
||||
// ApiJsonOptions sets PropertyNamingPolicy = CamelCase but NO DictionaryKeyPolicy:
|
||||
// property names camel-case, dictionary keys (canonical script refs with a PascalCase
|
||||
// type prefix) serialize verbatim. Pin that asymmetry so a future serializer change
|
||||
// (e.g. adding DictionaryKeyPolicy = CamelCase) fails loudly here.
|
||||
var typed = new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004"] = "Map drives",
|
||||
["Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005"] = null,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(typed, ApiJsonOptions.Create());
|
||||
|
||||
Assert.Contains("\"refs\":", json); // property name IS camel-cased
|
||||
Assert.Contains("\"Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004\"", json);
|
||||
Assert.Contains("\"Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005\":null", json);
|
||||
Assert.DoesNotContain("\"platform:", json);
|
||||
Assert.DoesNotContain("\"remediation:", json);
|
||||
}
|
||||
|
||||
// ---- GetGraphPermissionsStatus -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGraphPermissionsStatusResponse_matches_the_authoritative_shape()
|
||||
{
|
||||
var clientId = "6cef8fa7-9163-40cf-af56-52f3aaaa1006";
|
||||
var grantedRoles = new[] { "DeviceManagementConfiguration.Read.All" };
|
||||
var features = new List<GraphFeatureStatusItem>
|
||||
{
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "ScriptDisplayNames",
|
||||
Granted = true,
|
||||
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "W365CloudPcValidation",
|
||||
Granted = false,
|
||||
RequiredPermissions = new[] { "CloudPC.Read.All" },
|
||||
},
|
||||
};
|
||||
var anonymousFeatures = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "ScriptDisplayNames",
|
||||
granted = (bool?)true,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "W365CloudPcValidation",
|
||||
granted = (bool?)false,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "CloudPC.Read.All" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
clientId,
|
||||
isTransient = false,
|
||||
grantedRoles,
|
||||
features = anonymousFeatures,
|
||||
},
|
||||
new GetGraphPermissionsStatusResponse
|
||||
{
|
||||
ClientId = clientId,
|
||||
IsTransient = false,
|
||||
GrantedRoles = grantedRoles,
|
||||
Features = features,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGraphPermissionsStatusResponse_omits_granted_on_a_transient_snapshot()
|
||||
{
|
||||
// Old site: `granted = snapshot.IsTransient ? (bool?)null : ...` — WhenWritingNull
|
||||
// removed the key per feature row; the typed bool? must vanish identically.
|
||||
var anonymousFeatures = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "ScriptDisplayNames",
|
||||
granted = (bool?)null,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
};
|
||||
var typedFeatures = new List<GraphFeatureStatusItem>
|
||||
{
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "ScriptDisplayNames",
|
||||
Granted = null,
|
||||
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
clientId = string.Empty,
|
||||
isTransient = true,
|
||||
grantedRoles = Array.Empty<string>(),
|
||||
features = anonymousFeatures,
|
||||
},
|
||||
new GetGraphPermissionsStatusResponse
|
||||
{
|
||||
ClientId = string.Empty,
|
||||
IsTransient = true,
|
||||
GrantedRoles = Array.Empty<string>(),
|
||||
Features = typedFeatures,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RefreshGraphPermissions ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RefreshGraphPermissions_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Infrastructure / Global / Notifications / Bootstrap /
|
||||
/// Diagnostics function folders plus McpQuotaEnforcementMiddleware (anonymous-object →
|
||||
/// typed-DTO migration). Each fact serializes the OLD anonymous literal exactly as it stood
|
||||
/// at the call site (copied from the pre-migration code, filled with realistic sample
|
||||
/// values) against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class InfrastructureWireParityTests
|
||||
{
|
||||
// ---- AuthFunction: auth/me success body ----------------------------------------------
|
||||
// Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse.
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_matches_the_success_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "admin@contoso.com";
|
||||
var displayName = "Admin User";
|
||||
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = false,
|
||||
isTenantAdmin = true,
|
||||
isDelegated = true,
|
||||
delegatedTenantIds = managedTenantIds,
|
||||
role = "Admin",
|
||||
canManageBootstrapTokens = true,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = false,
|
||||
IsTenantAdmin = true,
|
||||
IsDelegated = true,
|
||||
DelegatedTenantIds = managedTenantIds,
|
||||
Role = "Admin",
|
||||
CanManageBootstrapTokens = true,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_omits_a_null_role()
|
||||
{
|
||||
// A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal,
|
||||
// so the key vanished — the DTO must vanish it identically.
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "reader@contoso.com";
|
||||
var displayName = "Reader User";
|
||||
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
|
||||
string? role = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = true,
|
||||
isTenantAdmin = false,
|
||||
isDelegated = false,
|
||||
delegatedTenantIds = Array.Empty<string>(),
|
||||
role,
|
||||
canManageBootstrapTokens = false,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = true,
|
||||
IsTenantAdmin = false,
|
||||
IsDelegated = false,
|
||||
DelegatedTenantIds = Array.Empty<string>(),
|
||||
Role = null,
|
||||
CanManageBootstrapTokens = false,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: IsGlobalAdmin -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void IsGlobalAdminResponse_matches_the_flag_shape()
|
||||
{
|
||||
var isAdmin = true;
|
||||
string? upn = "admin@contoso.com";
|
||||
|
||||
AssertParity(
|
||||
new { isGlobalAdmin = isAdmin, upn },
|
||||
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = upn });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsGlobalAdminResponse_omits_a_null_upn()
|
||||
{
|
||||
var isAdmin = false;
|
||||
string? upn = null;
|
||||
|
||||
AssertParity(
|
||||
new { isGlobalAdmin = isAdmin, upn },
|
||||
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: CheckMcpAccess (auth/mcp) --------------------------------------
|
||||
// Left side: the exact Dictionary<string, object?> the site built before the DTO —
|
||||
// insertion order == wire order, conditional keys only present when the tier applies.
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_plain_user_shape()
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = true,
|
||||
["upn"] = "analyst@contoso.com",
|
||||
["accessGrant"] = "whitelist",
|
||||
["reason"] = "whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "analyst@contoso.com",
|
||||
AccessGrant = "whitelist",
|
||||
Reason = "whitelisted",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape()
|
||||
{
|
||||
var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" };
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = true,
|
||||
["upn"] = "admin@contoso.com",
|
||||
["accessGrant"] = "global-admin",
|
||||
["reason"] = "platform role",
|
||||
["isGlobalAdmin"] = true,
|
||||
["globalRole"] = "GlobalAdmin",
|
||||
["delegatedTenantIds"] = delegatedTenantIds,
|
||||
["delegatedRole"] = "DelegatedAdmin",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "admin@contoso.com",
|
||||
AccessGrant = "global-admin",
|
||||
Reason = "platform role",
|
||||
IsGlobalAdmin = true,
|
||||
GlobalRole = "GlobalAdmin",
|
||||
DelegatedTenantIds = delegatedTenantIds,
|
||||
DelegatedRole = "DelegatedAdmin",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_denied_shape()
|
||||
{
|
||||
// 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never
|
||||
// appear as false — the DTO's null slot omits it exactly like the old dictionary did.
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = false,
|
||||
["upn"] = "user@contoso.com",
|
||||
["accessGrant"] = "",
|
||||
["reason"] = "not whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = false,
|
||||
Upn = "user@contoso.com",
|
||||
AccessGrant = "",
|
||||
Reason = "not whitelisted",
|
||||
IsGlobalAdmin = null,
|
||||
GlobalRole = null,
|
||||
DelegatedTenantIds = null,
|
||||
DelegatedRole = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ----------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the
|
||||
// raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalAdminsResponse_pins_the_row_listing_shape()
|
||||
{
|
||||
var admins = new List<GlobalAdminRow>
|
||||
{
|
||||
new GlobalAdminRow
|
||||
{
|
||||
Upn = "admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "root@fabrikam.com",
|
||||
Role = "GlobalAdmin",
|
||||
},
|
||||
new GlobalAdminRow
|
||||
{
|
||||
Upn = "reader@fabrikam.com",
|
||||
IsEnabled = false,
|
||||
AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "GlobalReader",
|
||||
},
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins });
|
||||
Assert.Equal(
|
||||
"{\"admins\":["
|
||||
+ "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"},"
|
||||
+ "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGlobalAdminResponse_pins_the_created_row_shape()
|
||||
{
|
||||
var newAdmin = new GlobalAdminRow
|
||||
{
|
||||
Upn = "new.admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "GlobalAdmin",
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}",
|
||||
TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: HealthCheck ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void HealthCheckResponse_matches_the_liveness_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
|
||||
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
status = "healthy",
|
||||
service = "Autopilot Monitor API",
|
||||
timestamp,
|
||||
version = "1.2.3",
|
||||
commitHash = "abc1234",
|
||||
buildUtc
|
||||
},
|
||||
new HealthCheckResponse
|
||||
{
|
||||
Status = "healthy",
|
||||
Service = "Autopilot Monitor API",
|
||||
Timestamp = timestamp,
|
||||
Version = "1.2.3",
|
||||
CommitHash = "abc1234",
|
||||
BuildUtc = buildUtc
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: DetailedHealthCheck ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DetailedHealthCheckResponse_matches_the_report_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
|
||||
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
|
||||
var visibleChecks = new List<HealthCheck>
|
||||
{
|
||||
new HealthCheck
|
||||
{
|
||||
Name = "Table Storage",
|
||||
Description = "Round-trip against the sessions table",
|
||||
Status = "healthy",
|
||||
Message = "OK",
|
||||
Details = new Dictionary<string, object> { ["latencyMs"] = 12 },
|
||||
},
|
||||
new HealthCheck
|
||||
{
|
||||
Name = "SignalR",
|
||||
Description = "Service reachability",
|
||||
Status = "degraded",
|
||||
Message = "Slow response",
|
||||
// Details stays null → the key vanishes inside the item on both sides.
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
service = "Autopilot Monitor API",
|
||||
timestamp,
|
||||
overallStatus = "degraded",
|
||||
checks = visibleChecks,
|
||||
version = "1.2.3",
|
||||
commitHash = "abc1234",
|
||||
buildUtc
|
||||
},
|
||||
new DetailedHealthCheckResponse
|
||||
{
|
||||
Service = "Autopilot Monitor API",
|
||||
Timestamp = timestamp,
|
||||
OverallStatus = "degraded",
|
||||
Checks = visibleChecks,
|
||||
Version = "1.2.3",
|
||||
CommitHash = "abc1234",
|
||||
BuildUtc = buildUtc
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: McpHealthCheck ---------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void McpHealthCheckResponse_matches_the_probe_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc);
|
||||
var check = new HealthCheck
|
||||
{
|
||||
Name = "MCP Server",
|
||||
Description = "Remote MCP server reachability",
|
||||
Status = "healthy",
|
||||
Message = "Responded in 480ms",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
timestamp,
|
||||
check
|
||||
},
|
||||
new McpHealthCheckResponse
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Check = check
|
||||
});
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: GetMcpUsers ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetMcpUsersResponse_matches_the_whitelist_shape()
|
||||
{
|
||||
var users = new List<McpUserEntry>
|
||||
{
|
||||
new McpUserEntry
|
||||
{
|
||||
Upn = "analyst@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedAt = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
UsagePlan = "pro",
|
||||
},
|
||||
new McpUserEntry
|
||||
{
|
||||
Upn = "viewer@fabrikam.com",
|
||||
IsEnabled = false,
|
||||
AddedAt = new DateTime(2026, 8, 11, 8, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
// UsagePlan stays null → the key vanishes inside the item on both sides.
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { policy = "Whitelist", users },
|
||||
new GetMcpUsersResponse { Policy = "Whitelist", Users = users });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: AddMcpUser -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddMcpUserResponse_matches_the_created_entry_shape()
|
||||
{
|
||||
var user = new McpUserEntry
|
||||
{
|
||||
Upn = "new.analyst@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedAt = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { user },
|
||||
new AddMcpUserResponse { User = user });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: SetMcpUserUsagePlan --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetMcpUserUsagePlanResponse_matches_the_plan_shape()
|
||||
{
|
||||
var upn = "analyst@contoso.com";
|
||||
string? usagePlan = "pro";
|
||||
|
||||
AssertParity(
|
||||
new { upn, usagePlan = usagePlan ?? "(inherit)" },
|
||||
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetMcpUserUsagePlanResponse_renders_the_inherit_fallback()
|
||||
{
|
||||
var upn = "analyst@contoso.com";
|
||||
string? usagePlan = null;
|
||||
|
||||
AssertParity(
|
||||
new { upn, usagePlan = usagePlan ?? "(inherit)" },
|
||||
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
|
||||
}
|
||||
|
||||
// ---- SignalRNegotiateFunction --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SignalRNegotiateResponse_matches_the_negotiate_protocol_shape()
|
||||
{
|
||||
var url = "https://signalr.service.example/client/?hub=notifications";
|
||||
var accessToken = "eyJhbGciOiJIUzI1NiJ9.sample.token";
|
||||
|
||||
AssertParity(
|
||||
new { url, accessToken },
|
||||
new SignalRNegotiateResponse { Url = url, AccessToken = accessToken });
|
||||
}
|
||||
|
||||
// ---- SignalRAddToGroup / SignalRRemoveFromGroup --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddToGroup_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var groupName = "tenant-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added to group {groupName}"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Added to group {groupName}"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFromGroup_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var groupName = "session-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d-0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed from group {groupName}"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Removed from group {groupName}"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GlobalNotificationsFunction / TenantNotificationsFunction: list -----------------
|
||||
|
||||
[Fact]
|
||||
public void NotificationListResponse_matches_the_active_notification_shape()
|
||||
{
|
||||
var notifications = new List<GlobalNotificationDto>
|
||||
{
|
||||
new GlobalNotificationDto
|
||||
{
|
||||
Id = "8f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
|
||||
Type = "preview_signup",
|
||||
Title = "New Tenant Signup",
|
||||
Message = "Tenant 6a6a35a2 (contoso.com), UPN: admin@contoso.com",
|
||||
Href = "/admin/tenants/management?tenantId=6a6a35a2",
|
||||
CreatedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new GlobalNotificationDto
|
||||
{
|
||||
Id = "8f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
Type = "info",
|
||||
Title = "Maintenance",
|
||||
Message = "Nightly cleanup completed",
|
||||
// Href stays null → the key vanishes inside the item on both sides.
|
||||
CreatedAt = new DateTime(2026, 8, 30, 4, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, notifications },
|
||||
new NotificationListResponse { Success = true, Notifications = notifications });
|
||||
}
|
||||
|
||||
// ---- Global/Tenant notifications: dismiss single -------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DismissNotification_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
// ---- Global/Tenant notifications: dismiss-all ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DismissAllNotificationsResponse_matches_the_count_shape()
|
||||
{
|
||||
var dismissedCount = 7;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, dismissedCount },
|
||||
new DismissAllNotificationsResponse { Success = true, DismissedCount = dismissedCount });
|
||||
}
|
||||
|
||||
// ---- RevokeBootstrapSessionFunction --------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RevokeBootstrapSession_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true, message = "Bootstrap session revoked" },
|
||||
new SuccessMessageResponse { Success = true, Message = "Bootstrap session revoked" });
|
||||
}
|
||||
|
||||
// ---- DiagnosticsDownloadTicketFunction -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsDownloadTicketResponse_matches_the_ticket_shape()
|
||||
{
|
||||
var ticket = "dGVzdC10aWNrZXQ=.sig==";
|
||||
var expiresAt = new DateTime(2026, 8, 30, 15, 10, 0, DateTimeKind.Utc);
|
||||
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-1111-4d61-9c93-0aa111111111/diagnostics.zip";
|
||||
var destinationLabel = "Hosted";
|
||||
long? sizeBytes = 1048576;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
expiresAt,
|
||||
blobName,
|
||||
destination = destinationLabel,
|
||||
sizeBytes,
|
||||
},
|
||||
new DiagnosticsDownloadTicketResponse
|
||||
{
|
||||
Success = true,
|
||||
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
ExpiresAt = expiresAt,
|
||||
BlobName = blobName,
|
||||
Destination = destinationLabel,
|
||||
SizeBytes = sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsDownloadTicketResponse_omits_a_null_sizeBytes()
|
||||
{
|
||||
var ticket = "dGVzdC10aWNrZXQy.sig==";
|
||||
var expiresAt = new DateTime(2026, 8, 30, 15, 20, 0, DateTimeKind.Utc);
|
||||
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-2222-4d61-9c93-0aa222222222/diagnostics.zip";
|
||||
var destinationLabel = "CustomerSas";
|
||||
long? sizeBytes = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
expiresAt,
|
||||
blobName,
|
||||
destination = destinationLabel,
|
||||
sizeBytes,
|
||||
},
|
||||
new DiagnosticsDownloadTicketResponse
|
||||
{
|
||||
Success = true,
|
||||
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
ExpiresAt = expiresAt,
|
||||
BlobName = blobName,
|
||||
Destination = destinationLabel,
|
||||
SizeBytes = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "community",
|
||||
Scope = "daily",
|
||||
DailyLimit = 200,
|
||||
MonthlyLimit = 2000,
|
||||
DailyUsed = 200,
|
||||
MonthlyUsed = 940,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_monthly_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "pro",
|
||||
Scope = "monthly",
|
||||
DailyLimit = 1000,
|
||||
MonthlyLimit = 10000,
|
||||
DailyUsed = 120,
|
||||
MonthlyUsed = 10000,
|
||||
ResetUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_omits_a_null_scope()
|
||||
{
|
||||
// Unreachable at the 429 site (a blocked decision always names the exceeded window),
|
||||
// but the DTO slot is nullable — prove the key vanishes identically on both sides.
|
||||
string? scope = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -181,13 +181,7 @@ public class KillSwitchEvaluatorTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var evaluator = new KillSwitchEvaluator(
|
||||
|
||||
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
|
||||
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
return TestWire.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Metrics function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class MetricsWireParityTests
|
||||
{
|
||||
// ---- GetDeviceHistory ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetDeviceHistoryResponse_matches_the_history_shape()
|
||||
{
|
||||
DeviceHistory? history = new DeviceHistory
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialKey = "sn-0042",
|
||||
SerialNumber = "SN-0042",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
Chain = new List<DeviceSessionRef>
|
||||
{
|
||||
new DeviceSessionRef
|
||||
{
|
||||
SessionId = "0b6f7a37-1111-4d61-9c93-0aa111111111",
|
||||
StartedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = new DateTime(2026, 8, 28, 10, 5, 0, DateTimeKind.Utc),
|
||||
Status = "Failed",
|
||||
EnrollmentType = "v1",
|
||||
IsPreProvisioned = false,
|
||||
DurationSeconds = 3900,
|
||||
AdminMarked = false,
|
||||
},
|
||||
new DeviceSessionRef
|
||||
{
|
||||
SessionId = "0b6f7a37-2222-4d61-9c93-0aa222222222",
|
||||
StartedAt = new DateTime(2026, 8, 28, 11, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = new DateTime(2026, 8, 28, 11, 50, 0, DateTimeKind.Utc),
|
||||
Status = "Succeeded",
|
||||
EnrollmentType = "v1",
|
||||
IsPreProvisioned = false,
|
||||
DurationSeconds = 3000,
|
||||
AdminMarked = false,
|
||||
},
|
||||
},
|
||||
CurrentJourneyAttempts = 2,
|
||||
JourneyCount = 1,
|
||||
JourneyVersion = 1,
|
||||
LastUpdated = new DateTime(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
int? attemptNumber = 2;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, history, attemptNumber },
|
||||
new GetDeviceHistoryResponse
|
||||
{
|
||||
Success = true,
|
||||
History = history,
|
||||
AttemptNumber = attemptNumber,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDeviceHistoryResponse_omits_a_null_history_and_attemptNumber()
|
||||
{
|
||||
DeviceHistory? history = null;
|
||||
int? attemptNumber = null;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, history, attemptNumber },
|
||||
new GetDeviceHistoryResponse
|
||||
{
|
||||
Success = true,
|
||||
History = null,
|
||||
AttemptNumber = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGeographicLocationSessions / GetGlobalGeographicLocationSessions -------------
|
||||
|
||||
private static LocationSessionRow SampleLocationRow(
|
||||
string sessionId, DateTime? completedAt, int? durationSeconds) => new LocationSessionRow
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
StartedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = completedAt,
|
||||
Status = SessionStatus.Succeeded,
|
||||
FailureReason = string.Empty,
|
||||
DurationSeconds = durationSeconds,
|
||||
EnrollmentType = "v1",
|
||||
GeoCountry = "DE",
|
||||
GeoRegion = "Hessen",
|
||||
GeoCity = "Frankfurt",
|
||||
TotalAppCount = 7,
|
||||
HasDoTelemetry = true,
|
||||
DoPercentPeerCaching = 42.5,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsResponse_matches_the_full_shape()
|
||||
{
|
||||
var rows = new List<LocationSessionRow>
|
||||
{
|
||||
SampleLocationRow(
|
||||
"3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
|
||||
new DateTime(2026, 8, 30, 9, 10, 0, DateTimeKind.Utc),
|
||||
4200),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = rows, totalCount = rows.Count },
|
||||
new GeographicLocationSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = rows,
|
||||
TotalCount = rows.Count,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsLeanResponse_matches_the_lean_projection_shape()
|
||||
{
|
||||
var r = SampleLocationRow(
|
||||
"3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
new DateTime(2026, 8, 30, 9, 40, 0, DateTimeKind.Utc),
|
||||
5100);
|
||||
|
||||
// Old site: ToLeanRow returned `object` — an anonymous projection of the row.
|
||||
var lean = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = r.SessionId,
|
||||
tenantId = r.TenantId,
|
||||
serialNumber = r.SerialNumber,
|
||||
deviceName = r.DeviceName,
|
||||
manufacturer = r.Manufacturer,
|
||||
model = r.Model,
|
||||
startedAt = r.StartedAt,
|
||||
completedAt = r.CompletedAt,
|
||||
status = r.Status,
|
||||
failureReason = r.FailureReason,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
enrollmentType = r.EnrollmentType,
|
||||
geoCountry = r.GeoCountry,
|
||||
geoCity = r.GeoCity,
|
||||
totalAppCount = r.TotalAppCount,
|
||||
hasDoTelemetry = r.HasDoTelemetry,
|
||||
doPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
var leanTyped = new List<LocationSessionLeanRow>
|
||||
{
|
||||
new LocationSessionLeanRow
|
||||
{
|
||||
SessionId = r.SessionId,
|
||||
TenantId = r.TenantId,
|
||||
SerialNumber = r.SerialNumber,
|
||||
DeviceName = r.DeviceName,
|
||||
Manufacturer = r.Manufacturer,
|
||||
Model = r.Model,
|
||||
StartedAt = r.StartedAt,
|
||||
CompletedAt = r.CompletedAt,
|
||||
Status = r.Status,
|
||||
FailureReason = r.FailureReason,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
EnrollmentType = r.EnrollmentType,
|
||||
GeoCountry = r.GeoCountry,
|
||||
GeoCity = r.GeoCity,
|
||||
TotalAppCount = r.TotalAppCount,
|
||||
HasDoTelemetry = r.HasDoTelemetry,
|
||||
DoPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = lean, totalCount = lean.Count },
|
||||
new GeographicLocationSessionsLeanResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = leanTyped,
|
||||
TotalCount = leanTyped.Count,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsLeanResponse_omits_null_completedAt_and_durationSeconds()
|
||||
{
|
||||
var r = SampleLocationRow("3f1c9d55-cccc-4e0e-8888-52f3aaaa0003", null, null);
|
||||
|
||||
var lean = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = r.SessionId,
|
||||
tenantId = r.TenantId,
|
||||
serialNumber = r.SerialNumber,
|
||||
deviceName = r.DeviceName,
|
||||
manufacturer = r.Manufacturer,
|
||||
model = r.Model,
|
||||
startedAt = r.StartedAt,
|
||||
completedAt = r.CompletedAt,
|
||||
status = r.Status,
|
||||
failureReason = r.FailureReason,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
enrollmentType = r.EnrollmentType,
|
||||
geoCountry = r.GeoCountry,
|
||||
geoCity = r.GeoCity,
|
||||
totalAppCount = r.TotalAppCount,
|
||||
hasDoTelemetry = r.HasDoTelemetry,
|
||||
doPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
var leanTyped = new List<LocationSessionLeanRow>
|
||||
{
|
||||
new LocationSessionLeanRow
|
||||
{
|
||||
SessionId = r.SessionId,
|
||||
TenantId = r.TenantId,
|
||||
SerialNumber = r.SerialNumber,
|
||||
DeviceName = r.DeviceName,
|
||||
Manufacturer = r.Manufacturer,
|
||||
Model = r.Model,
|
||||
StartedAt = r.StartedAt,
|
||||
CompletedAt = null,
|
||||
Status = r.Status,
|
||||
FailureReason = r.FailureReason,
|
||||
DurationSeconds = null,
|
||||
EnrollmentType = r.EnrollmentType,
|
||||
GeoCountry = r.GeoCountry,
|
||||
GeoCity = r.GeoCity,
|
||||
TotalAppCount = r.TotalAppCount,
|
||||
HasDoTelemetry = r.HasDoTelemetry,
|
||||
DoPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = lean, totalCount = lean.Count },
|
||||
new GeographicLocationSessionsLeanResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = leanTyped,
|
||||
TotalCount = leanTyped.Count,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetPlatformStats ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformStatsResponse_matches_the_zero_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
totalEnrollments = 0,
|
||||
totalUsers = 0,
|
||||
totalTenants = 0,
|
||||
uniqueDeviceModels = 0,
|
||||
totalEventsProcessed = 0,
|
||||
successfulEnrollments = 0,
|
||||
issuesDetected = 0,
|
||||
lastUpdated = (DateTime?)null
|
||||
},
|
||||
new GetPlatformStatsResponse
|
||||
{
|
||||
TotalEnrollments = 0,
|
||||
TotalUsers = 0,
|
||||
TotalTenants = 0,
|
||||
// TotalSignedUpTenants stays null → the key is absent, exactly like the old
|
||||
// literal that never declared it.
|
||||
UniqueDeviceModels = 0,
|
||||
TotalEventsProcessed = 0,
|
||||
SuccessfulEnrollments = 0,
|
||||
IssuesDetected = 0,
|
||||
LastUpdated = null
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformStatsResponse_matches_the_computed_stats_shape()
|
||||
{
|
||||
var stats = new PlatformStats
|
||||
{
|
||||
TotalEnrollments = 123456,
|
||||
TotalUsers = 789,
|
||||
TotalTenants = 321,
|
||||
TotalSignedUpTenants = 345,
|
||||
UniqueDeviceModels = 210,
|
||||
TotalEventsProcessed = 9876543,
|
||||
SuccessfulEnrollments = 111111,
|
||||
IssuesDetected = 2222,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
totalEnrollments = stats.TotalEnrollments,
|
||||
totalUsers = stats.TotalUsers,
|
||||
totalTenants = stats.TotalTenants,
|
||||
totalSignedUpTenants = stats.TotalSignedUpTenants,
|
||||
uniqueDeviceModels = stats.UniqueDeviceModels,
|
||||
totalEventsProcessed = stats.TotalEventsProcessed,
|
||||
successfulEnrollments = stats.SuccessfulEnrollments,
|
||||
issuesDetected = stats.IssuesDetected
|
||||
},
|
||||
new GetPlatformStatsResponse
|
||||
{
|
||||
TotalEnrollments = stats.TotalEnrollments,
|
||||
TotalUsers = stats.TotalUsers,
|
||||
TotalTenants = stats.TotalTenants,
|
||||
TotalSignedUpTenants = stats.TotalSignedUpTenants,
|
||||
UniqueDeviceModels = stats.UniqueDeviceModels,
|
||||
TotalEventsProcessed = stats.TotalEventsProcessed,
|
||||
SuccessfulEnrollments = stats.SuccessfulEnrollments,
|
||||
IssuesDetected = stats.IssuesDetected
|
||||
// LastUpdated stays null → the key is absent, exactly like the old literal
|
||||
// that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionTimeAttribution -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionTimeAttributionResponse_matches_the_breakdown_shape()
|
||||
{
|
||||
SessionTimeBreakdown? breakdown = new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004",
|
||||
AttributionVersion = 2,
|
||||
EventCountAtCompute = 250,
|
||||
WallClockSeconds = 3600,
|
||||
UnattributedSeconds = 120,
|
||||
RebootSeconds = 90,
|
||||
SleepSeconds = 0,
|
||||
BlockingAppCount = 3,
|
||||
EspAppsOccupancySeconds = 1500,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, breakdown },
|
||||
new GetSessionTimeAttributionResponse
|
||||
{
|
||||
Success = true,
|
||||
Breakdown = breakdown,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionTimeAttributionResponse_omits_a_null_breakdown()
|
||||
{
|
||||
SessionTimeBreakdown? breakdown = null;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, breakdown },
|
||||
new GetSessionTimeAttributionResponse
|
||||
{
|
||||
Success = true,
|
||||
Breakdown = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetMyMcpUsage -------------------------------------------------------------------
|
||||
|
||||
private static List<UserUsageRecord> SampleUsageRecords(string userId) => new List<UserUsageRecord>
|
||||
{
|
||||
new UserUsageRecord
|
||||
{
|
||||
UserId = userId,
|
||||
UserPrincipalName = "admin@contoso.com",
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
Endpoint = "get_session_summary",
|
||||
Date = "20260830",
|
||||
RequestCount = 12,
|
||||
LastRequestAt = new DateTime(2026, 8, 30, 14, 30, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GetMyMcpUsageResponse_matches_the_self_service_shape()
|
||||
{
|
||||
var userId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
|
||||
string? upn = "admin@contoso.com";
|
||||
string? usagePlan = "pro";
|
||||
var effectivePlan = "pro";
|
||||
var dailyLimit = 500;
|
||||
var monthlyLimit = 10000;
|
||||
long dailyUsed = 42;
|
||||
long monthlyUsed = 950;
|
||||
var resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
var records = SampleUsageRecords(userId);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
userId,
|
||||
upn,
|
||||
usagePlan,
|
||||
effectivePlan,
|
||||
quota = new
|
||||
{
|
||||
dailyLimit,
|
||||
monthlyLimit,
|
||||
dailyUsed,
|
||||
monthlyUsed,
|
||||
resetUtc
|
||||
},
|
||||
records
|
||||
},
|
||||
new GetMyMcpUsageResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Upn = upn,
|
||||
UsagePlan = usagePlan,
|
||||
EffectivePlan = effectivePlan,
|
||||
Quota = new McpUsageQuotaNode
|
||||
{
|
||||
DailyLimit = dailyLimit,
|
||||
MonthlyLimit = monthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
ResetUtc = resetUtc
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMyMcpUsageResponse_omits_null_upn_and_usagePlan()
|
||||
{
|
||||
var userId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0008";
|
||||
string? upn = null;
|
||||
string? usagePlan = null;
|
||||
var effectivePlan = "free";
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
userId,
|
||||
upn,
|
||||
usagePlan,
|
||||
effectivePlan,
|
||||
quota = new
|
||||
{
|
||||
dailyLimit = 50,
|
||||
monthlyLimit = 1000,
|
||||
dailyUsed = 0L,
|
||||
monthlyUsed = 0L,
|
||||
resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
records
|
||||
},
|
||||
new GetMyMcpUsageResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Upn = null,
|
||||
UsagePlan = null,
|
||||
EffectivePlan = effectivePlan,
|
||||
Quota = new McpUsageQuotaNode
|
||||
{
|
||||
DailyLimit = 50,
|
||||
MonthlyLimit = 1000,
|
||||
DailyUsed = 0,
|
||||
MonthlyUsed = 0,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetMcpUserUsage -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetMcpUserUsageResponse_matches_the_user_usage_shape()
|
||||
{
|
||||
var userId = "8bb31d22-0003-4b7c-a1d2-52f3aaaa0009";
|
||||
var records = SampleUsageRecords(userId);
|
||||
|
||||
AssertParity(
|
||||
new { userId, records },
|
||||
new GetMcpUserUsageResponse { UserId = userId, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var summaries = new List<UserUsageDailySummary>
|
||||
{
|
||||
new UserUsageDailySummary
|
||||
{
|
||||
Date = "20260830",
|
||||
TenantId = tenantId,
|
||||
TotalRequests = 321,
|
||||
UniqueUsers = 4,
|
||||
UniqueEndpoints = 11,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var summaries = new List<UserUsageDailySummary>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
|
||||
}
|
||||
|
||||
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryResponse_matches_the_tally_shape()
|
||||
{
|
||||
var days = 30;
|
||||
// Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the
|
||||
// MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical
|
||||
// (incl. the windowDays duplicate on every item).
|
||||
var summary = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
totalSessions = 10,
|
||||
succeeded = 6,
|
||||
failed = 2,
|
||||
inProgress = 1,
|
||||
pending = 0,
|
||||
stalled = 0,
|
||||
awaitingUser = 0,
|
||||
incomplete = 1,
|
||||
other = 0,
|
||||
failureRate = 25.0,
|
||||
windowDays = days
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, summary, windowDays = days },
|
||||
new MetricsSummaryResponse
|
||||
{
|
||||
Success = true,
|
||||
Summary = new List<MetricsSummaryTenantItem>
|
||||
{
|
||||
new MetricsSummaryTenantItem
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
TotalSessions = 10,
|
||||
Succeeded = 6,
|
||||
Failed = 2,
|
||||
InProgress = 1,
|
||||
Pending = 0,
|
||||
Stalled = 0,
|
||||
AwaitingUser = 0,
|
||||
Incomplete = 1,
|
||||
Other = 0,
|
||||
FailureRate = 25.0,
|
||||
WindowDays = days,
|
||||
},
|
||||
},
|
||||
WindowDays = days,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetRuleHitSessions --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRuleHitSessionsResponse_matches_the_hit_listing_shape()
|
||||
{
|
||||
var ruleId = "APP-017";
|
||||
var days = 14;
|
||||
var sessionIds = new List<string>
|
||||
{
|
||||
"add53f44-0005-4b7c-a1d2-52f3aaaa0011",
|
||||
"bee64055-0006-4b7c-a1d2-52f3aaaa0012",
|
||||
};
|
||||
const int maxSessionIds = 2000;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
ruleId,
|
||||
days,
|
||||
sessionIds,
|
||||
truncated = sessionIds.Count >= maxSessionIds
|
||||
},
|
||||
new GetRuleHitSessionsResponse
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Days = days,
|
||||
SessionIds = sessionIds,
|
||||
Truncated = sessionIds.Count >= maxSessionIds
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
+1
-7
@@ -585,13 +585,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -451,13 +451,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -277,13 +277,7 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
return new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
return TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet against NEW anonymous-object API responses: <c>req.OkAsync(new { ... })</c> ships a
|
||||
/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the
|
||||
/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed
|
||||
/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests);
|
||||
/// the existing sites are frozen as a per-file baseline that may only SHRINK:
|
||||
/// - a new anonymous OkAsync (new file, or count above baseline) fails,
|
||||
/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed,
|
||||
/// so the baseline always reflects reality and the debt is visible in the diff.
|
||||
/// Typed object initializers (<c>OkAsync(new SomeResponse { ... })</c>) do not match.
|
||||
/// </summary>
|
||||
public class OkAsyncBaselineGuardTests
|
||||
{
|
||||
private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project.
|
||||
/// Only ever lower a count or delete an entry — never raise or add.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> Baseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Admin/GetActiveUsersFunction.cs"] = 1,
|
||||
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetOpsEventsFunction.cs"] = 2,
|
||||
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2,
|
||||
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2,
|
||||
["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1,
|
||||
["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3,
|
||||
["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1,
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = 2,
|
||||
["Functions/Raw/QueryRawSessionsFunction.cs"] = 1,
|
||||
["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1,
|
||||
["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1,
|
||||
["Functions/Reports/GetSessionReportsFunction.cs"] = 2,
|
||||
["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionEventsFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetSessionFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count;
|
||||
if (count > 0)
|
||||
actual[relative] = count;
|
||||
}
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Baseline.TryGetValue(file, out var allowed))
|
||||
violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead.");
|
||||
else if (count > allowed)
|
||||
violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
|
||||
}
|
||||
|
||||
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
|
||||
// list stays truthful (and its shrink is visible in the diff).
|
||||
foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
actual.TryGetValue(file, out var count);
|
||||
if (count < allowed)
|
||||
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers).
|
||||
/// Before this, an alert carried only category/event/severity and a tenant GUID.
|
||||
/// </summary>
|
||||
public class OpsAlertPayloadTests
|
||||
{
|
||||
// ── Fact flattening ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_ProjectsTopLevelScalars()
|
||||
{
|
||||
var facts = OpsAlertDispatchService.FlattenDetails(
|
||||
"""{"domainName":"contoso.example","daysLeft":3,"selfService":true}""");
|
||||
|
||||
Assert.Equal(3, facts.Count);
|
||||
Assert.Equal("Domain Name", facts[0].Name);
|
||||
Assert.Equal("contoso.example", facts[0].Value);
|
||||
Assert.Equal("Days Left", facts[1].Name);
|
||||
Assert.Equal("3", facts[1].Value);
|
||||
Assert.Equal("Self Service", facts[2].Name);
|
||||
Assert.Equal("true", facts[2].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_SkipsNullsAndNestedValues()
|
||||
{
|
||||
var facts = OpsAlertDispatchService.FlattenDetails(
|
||||
"""{"domainName":null,"nested":{"a":1},"list":[1,2],"keep":"yes"}""");
|
||||
|
||||
Assert.Equal(new[] { "Keep" }, facts.Select(f => f.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_CapsFactCount()
|
||||
{
|
||||
var props = string.Join(",", Enumerable.Range(0, 30).Select(i => $"\"p{i}\":\"v\""));
|
||||
|
||||
Assert.Equal(OpsAlertDispatchService.MaxDetailFacts,
|
||||
OpsAlertDispatchService.FlattenDetails("{" + props + "}").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_TruncatesLongValues()
|
||||
{
|
||||
var longValue = new string('x', 400);
|
||||
|
||||
var fact = Assert.Single(OpsAlertDispatchService.FlattenDetails($$"""{"note":"{{longValue}}"}"""));
|
||||
|
||||
Assert.Equal(OpsAlertDispatchService.MaxDetailValueLength + 1, fact.Value.Length); // + ellipsis
|
||||
Assert.EndsWith("…", fact.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("[1,2,3]")] // array root
|
||||
[InlineData("\"a string\"")] // scalar root
|
||||
public void FlattenDetails_MalformedOrNonObjectPayloadCostsNothing(string? json)
|
||||
=> Assert.Empty(OpsAlertDispatchService.FlattenDetails(json));
|
||||
|
||||
[Theory]
|
||||
[InlineData("domainName", "Domain Name")]
|
||||
[InlineData("trialExpiresUtc", "Trial Expires Utc")]
|
||||
[InlineData("id", "Id")]
|
||||
[InlineData("URL", "URL")] // consecutive capitals are not split
|
||||
public void Humanize_TitleCasesCamelCaseKeys(string input, string expected)
|
||||
=> Assert.Equal(expected, OpsAlertDispatchService.Humanize(input));
|
||||
|
||||
// ── Generic JSON renderer contract ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_EmbedsDataAsRealJsonNotAString()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "t", Summary = "s", EventType = "TenantTrialStarted",
|
||||
DataJson = """{"domainName":"contoso.example","selfService":true}""",
|
||||
};
|
||||
|
||||
var payload = JObject.Parse(new GenericJsonRenderer().RenderToJson(alert));
|
||||
|
||||
Assert.Equal(JTokenType.Object, payload["data"]!.Type);
|
||||
Assert.Equal("contoso.example", payload["data"]!["domainName"]!.Value<string>());
|
||||
Assert.True(payload["data"]!["selfService"]!.Value<bool>());
|
||||
Assert.Equal("TenantTrialStarted", payload["eventType"]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_OmitsDataWhenAbsentOrUnusable()
|
||||
{
|
||||
var renderer = new GenericJsonRenderer();
|
||||
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s" }))["data"]);
|
||||
|
||||
// Malformed or non-object payloads must not change the shape consumers parse against.
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = "not-json" }))["data"]);
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = "[1,2]" }))["data"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_SchemaVersionUnchangedByTheAdditiveDataKey()
|
||||
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
|
||||
|
||||
// ── Telegram text carries the same information ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramRendering_IncludesFlattenedFacts()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "Ops Alert: Tenant/TenantTrialStarted",
|
||||
Summary = "Pro trial started",
|
||||
Severity = NotificationSeverity.Info,
|
||||
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
|
||||
};
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
|
||||
Assert.Contains("Domain Name: contoso.example", text);
|
||||
Assert.Contains("Pro trial started", text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-category PAGED read fans out over a fixed category list. A category that is written
|
||||
/// but not listed is invisible to every paged reader (MCP get_ops_events, the ops dashboard) while
|
||||
/// the unpaged full-table path still returns it — a silent, one-sided blind spot. That happened to
|
||||
/// Platform (Azure Monitor alerts via the ops alert webhook), which was recorded from day one and
|
||||
/// never fanned out. These tests fail the build when a new OpsEventCategory constant is added
|
||||
/// without extending the fan-out list.
|
||||
/// </summary>
|
||||
public class OpsEventCategoryCoverageTests
|
||||
{
|
||||
private static string[] DeclaredCategories() =>
|
||||
typeof(OpsEventCategory)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void All_CoversEveryDeclaredCategoryConstant()
|
||||
{
|
||||
var declared = DeclaredCategories();
|
||||
|
||||
Assert.NotEmpty(declared);
|
||||
Assert.Equal(declared.OrderBy(c => c), OpsEventCategory.All.OrderBy(c => c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PagedFanOut_UsesTheFullCategoryVocabulary()
|
||||
{
|
||||
// Same array instance on purpose: the fan-out must not keep a private copy that can drift.
|
||||
Assert.Same(OpsEventCategory.All, TableOpsEventRepository.AllCategories);
|
||||
Assert.Contains(OpsEventCategory.Platform, TableOpsEventRepository.AllCategories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the ops-events field filters (eventType / severity / minSeverity). Two properties matter:
|
||||
/// (1) they are SERVER-SIDE — an operator asking for one event type must not pull a whole category
|
||||
/// over the wire, and the paged fan-out must honour the exact same clauses as the single-category
|
||||
/// and unpaged paths; (2) they are bound into the continuation fingerprint, so a token minted for
|
||||
/// one filter cannot page a different one.
|
||||
/// </summary>
|
||||
public class OpsEventFieldFilterTests
|
||||
{
|
||||
private static NameValueCollection Query(params (string Key, string Value)[] pairs)
|
||||
{
|
||||
var q = new NameValueCollection();
|
||||
foreach (var (k, v) in pairs) q[k] = v;
|
||||
return q;
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_NoFilterParams_YieldsEmptyFilters_AndNoExtras()
|
||||
{
|
||||
var result = OpsEventFilterRequest.Parse(Query(("category", "Agent")));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(result.Filters.IsEmpty);
|
||||
Assert.Empty(OpsEventFilterRequest.ToExtras(result.Filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EventType_IsVerbatim_NotCaseFolded()
|
||||
{
|
||||
// Table Storage cannot fold case: whatever the caller names is what the store is asked for.
|
||||
var result = OpsEventFilterRequest.Parse(Query(("eventType", "AgentEmergencyBreak")));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("AgentEmergencyBreak", result.Filters.EventType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("warning", "Warning")]
|
||||
[InlineData("ERROR", "Error")]
|
||||
[InlineData("Critical", "Critical")]
|
||||
public void Parse_Severity_IsNormalisedToCanonicalCasing(string input, string expected)
|
||||
{
|
||||
// Without normalisation a "?severity=warning" would produce a Severity eq 'warning'
|
||||
// clause, which matches zero rows and reads like "nothing happened".
|
||||
var result = OpsEventFilterRequest.Parse(Query(("severity", input)));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(expected, result.Filters.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnknownSeverity_IsRejected_NotSilentlyDropped()
|
||||
{
|
||||
var severity = OpsEventFilterRequest.Parse(Query(("severity", "Warn")));
|
||||
var minSeverity = OpsEventFilterRequest.Parse(Query(("minSeverity", "sev2")));
|
||||
|
||||
Assert.NotNull(severity.Error);
|
||||
Assert.Contains("severity must be one of", severity.Error);
|
||||
Assert.Contains("Warn", severity.Error);
|
||||
Assert.NotNull(minSeverity.Error);
|
||||
Assert.Contains("minSeverity must be one of", minSeverity.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToExtras_EmitsNormalisedValues_InFixedOrder()
|
||||
{
|
||||
// Fixed order + normalised values: the fingerprint minted here must match the one
|
||||
// recomputed when the echoed nextLink params come back on the follow-up request.
|
||||
var result = OpsEventFilterRequest.Parse(Query(
|
||||
("severity", "error"), ("eventType", "AgentEmergencyBreak"), ("minSeverity", "warning")));
|
||||
|
||||
var extras = OpsEventFilterRequest.ToExtras(result.Filters);
|
||||
|
||||
Assert.Equal(new[] { "eventType", "severity", "minSeverity" }, extras.Select(e => e.Key).ToArray());
|
||||
Assert.Equal(new[] { "AgentEmergencyBreak", "Error", "Warning" }, extras.Select(e => e.Value).ToArray());
|
||||
}
|
||||
|
||||
// ── Severity ladder ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SeverityLadder_RanksInfoLowest_CriticalHighest()
|
||||
{
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Info) < OpsEventSeverity.Rank(OpsEventSeverity.Warning));
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Warning) < OpsEventSeverity.Rank(OpsEventSeverity.Error));
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Error) < OpsEventSeverity.Rank(OpsEventSeverity.Critical));
|
||||
Assert.Equal(-1, OpsEventSeverity.Rank("Warn"));
|
||||
Assert.Equal(-1, OpsEventSeverity.Rank(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtOrAbove_ReturnsTheThresholdAndEverythingAboveIt()
|
||||
{
|
||||
Assert.Equal(new[] { "Warning", "Error", "Critical" }, OpsEventSeverity.AtOrAbove("Warning").ToArray());
|
||||
Assert.Equal(new[] { "Critical" }, OpsEventSeverity.AtOrAbove("Critical").ToArray());
|
||||
Assert.Equal(OpsEventSeverity.All, OpsEventSeverity.AtOrAbove("Info").ToArray());
|
||||
Assert.Empty(OpsEventSeverity.AtOrAbove("Warn"));
|
||||
}
|
||||
|
||||
// ── Clause building (both builders) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_EmitsServerSideClauses_ForEveryField()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null,
|
||||
new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", Severity = "Error" });
|
||||
|
||||
Assert.NotNull(f);
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", f);
|
||||
Assert.Contains("Severity eq 'Error'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_MinSeverity_ExpandsToOrSet_NotALexicographicRange()
|
||||
{
|
||||
// "Critical" < "Error" < "Info" < "Warning" lexicographically — a ge comparison would
|
||||
// silently return the wrong rows, so the threshold has to become an OR-set.
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Warning" });
|
||||
|
||||
Assert.NotNull(f);
|
||||
Assert.Contains("(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')", f);
|
||||
Assert.DoesNotContain("Severity ge", f);
|
||||
Assert.DoesNotContain("Severity eq 'Info'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_MinSeverityInfo_EmitsNoClause_ItIsTheFloor()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Info" });
|
||||
|
||||
Assert.Equal("PartitionKey eq 'Agent'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_NoFilters_IsUnchanged()
|
||||
{
|
||||
// Regression guard: the pre-existing filter surface must not shift when no field filter
|
||||
// is named — every stored continuation token depends on the same rows coming back.
|
||||
Assert.Equal(
|
||||
TableOpsEventRepository.BuildFilter("Agent", null, null),
|
||||
TableOpsEventRepository.BuildFilter("Agent", null, null, new OpsEventQueryFilters()));
|
||||
Assert.Null(TableOpsEventRepository.BuildFilter(null, null, null, new OpsEventQueryFilters()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_EscapesQuotes_SoAFilterCannotBreakOutOfItsLiteral()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
null, null, null, new OpsEventQueryFilters { EventType = "Agent'Break" });
|
||||
|
||||
Assert.Equal("EventType eq 'Agent''Break'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FanOutBuilder_HonoursTheSameFieldClauses_AsTheSingleCategoryBuilder()
|
||||
{
|
||||
// The all-categories paged path builds its own filter (it adds the per-partition RowKey
|
||||
// bound). If it skipped the field filters, "no category named" would silently return
|
||||
// unfiltered rows — the exact bug this test exists to prevent.
|
||||
var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" };
|
||||
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters);
|
||||
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut);
|
||||
Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut);
|
||||
Assert.Contains("RowKey gt '0009'", fanOut);
|
||||
}
|
||||
|
||||
// ── Continuation fingerprint ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsTheFieldFilters_SoATokenCannotCrossFilters()
|
||||
{
|
||||
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
string Fp(params (string Key, string Value)[] pairs) => DateWindowPagination.Fingerprint(
|
||||
scope: "ops-events", callerTenantId: caller, dateFrom: from, dateTo: to,
|
||||
extras: OpsEventFilterRequest.ToExtras(OpsEventFilterRequest.Parse(Query(pairs)).Filters));
|
||||
|
||||
var unfiltered = Fp();
|
||||
var byType = Fp(("eventType", "AgentEmergencyBreak"));
|
||||
var byOtherType = Fp(("eventType", "AgentTimeout"));
|
||||
var byMinSeverity = Fp(("minSeverity", "Error"));
|
||||
|
||||
Assert.NotEqual(unfiltered, byType);
|
||||
Assert.NotEqual(byType, byOtherType);
|
||||
Assert.NotEqual(unfiltered, byMinSeverity);
|
||||
// Casing is normalised before fingerprinting, so the same query in two spellings
|
||||
// shares one token instead of minting two that each reject the other.
|
||||
Assert.Equal(byMinSeverity, Fp(("minSeverity", "error")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_WithNoFieldFilters_MatchesThePreFilterToken()
|
||||
{
|
||||
// Backward compatibility: tokens minted before the field filters existed carry only the
|
||||
// category/tenantId extras. Appending the (empty) field extras must not change the hash,
|
||||
// or every in-flight pagination would break on deploy.
|
||||
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
var legacyExtras = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new KeyValuePair<string, string?>("category", "Agent"),
|
||||
};
|
||||
var withEmptyFilters = new List<KeyValuePair<string, string?>>(legacyExtras);
|
||||
withEmptyFilters.AddRange(OpsEventFilterRequest.ToExtras(new OpsEventQueryFilters()));
|
||||
|
||||
Assert.Equal(
|
||||
DateWindowPagination.Fingerprint("ops-events", caller, from, null, legacyExtras),
|
||||
DateWindowPagination.Fingerprint("ops-events", caller, from, null, withEmptyFilters));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Every ops event type the backend can write must be selectable in the portal's alert-rule
|
||||
/// editor (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>) — the "dual register" that
|
||||
/// used to be a memory-only convention. A type written but not listed cannot be routed to a
|
||||
/// Telegram/webhook rule, which is exactly how a new Warning stays silent.
|
||||
/// Every ops event type the backend can write must be (a) declared in the canonical
|
||||
/// <see cref="OpsEventTypes"/> vocabulary and (b) selectable in the portal's alert-rule editor
|
||||
/// (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>). A type written but not declared is
|
||||
/// invisible to every consumer that enumerates the vocabulary (the shared manifest, and through
|
||||
/// it the MCP); a type declared but not listed in the editor cannot be routed to a channel, which
|
||||
/// is exactly how a new Warning stays silent.
|
||||
/// </summary>
|
||||
public class OpsEventTypeDualRegisterTests
|
||||
{
|
||||
@@ -20,27 +24,78 @@ public class OpsEventTypeDualRegisterTests
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_backend_ops_event_type_is_listed_in_the_web_alert_rule_editor()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
var service = File.ReadAllText(Path.Combine(root, "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
|
||||
var web = File.ReadAllText(Path.Combine(root, "src", "Web", "autopilot-monitor-web", "app", "admin", "components", "OpsAlertRulesSection.tsx"));
|
||||
private static string OpsEventServiceSource() => File.ReadAllText(Path.Combine(
|
||||
RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
|
||||
|
||||
// WriteAsync(OpsEventCategory.X, "TypeName", ...) — the second argument is the wire type.
|
||||
var backendTypes = Regex.Matches(service, @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
|
||||
/// <summary>The vocabulary as the assembly declares it — reflection, not a source scan.</summary>
|
||||
private static string[] DeclaredTypes() =>
|
||||
typeof(OpsEventTypes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void All_listsEveryDeclaredConstant()
|
||||
{
|
||||
// OpsEventTypes.All is what the manifest and every enumerating consumer read.
|
||||
Assert.Equal(
|
||||
DeclaredTypes().OrderBy(t => t, StringComparer.Ordinal),
|
||||
OpsEventTypes.All.OrderBy(t => t, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_write_site_uses_the_vocabulary_constant_not_a_raw_literal()
|
||||
{
|
||||
// The guard that keeps the reflection above honest: a bare string at a call site would
|
||||
// write a real event type that the vocabulary — and therefore every consumer — never sees.
|
||||
var literalWrites = Regex.Matches(
|
||||
OpsEventServiceSource(),
|
||||
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
Assert.NotEmpty(backendTypes);
|
||||
|
||||
Assert.True(literalWrites.Count == 0,
|
||||
"OpsEventService writes these event types as raw string literals — declare them in " +
|
||||
"OpsEventTypes and use the constant: " + string.Join(", ", literalWrites));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_write_site_names_a_declared_constant()
|
||||
{
|
||||
// Catches the other direction of the same seam: `OpsEventTypes.Foo` cannot compile unless
|
||||
// it exists, so this asserts the extraction below still SEES the call sites — a refactor
|
||||
// that renames WriteAsync would otherwise silently reduce this suite to vacuous truth.
|
||||
var used = Regex.Matches(
|
||||
OpsEventServiceSource(),
|
||||
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*OpsEventTypes\.(\w+)")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(used.Count > 50,
|
||||
$"Only {used.Count} ops-event write sites found — the extraction pattern is likely " +
|
||||
"broken (WriteAsync renamed?), not the call sites.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_ops_event_type_is_listed_in_the_web_alert_rule_editor()
|
||||
{
|
||||
var web = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Web", "autopilot-monitor-web",
|
||||
"app", "admin", "components", "OpsAlertRulesSection.tsx"));
|
||||
|
||||
var webTypes = Regex.Matches(web, @"""([A-Za-z0-9]+)""")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = backendTypes.Where(t => !webTypes.Contains(t)).ToList();
|
||||
var missing = DeclaredTypes()
|
||||
.Where(t => !webTypes.Contains(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"Ops event types written by OpsEventService but missing from OPS_EVENT_TYPES in OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
|
||||
"Ops event types declared in OpsEventTypes but missing from OPS_EVENT_TYPES in " +
|
||||
"OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
|
||||
/// behavior survives the migration untouched, per-rule channel routing, and redaction of the
|
||||
/// new secret-bearing field.
|
||||
/// </summary>
|
||||
public class OpsNotificationChannelsTests
|
||||
{
|
||||
private static AdminConfiguration LegacyConfig(
|
||||
bool telegramEnabled = true, string telegramChat = "-1003785642894",
|
||||
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
|
||||
bool slackEnabled = false, string slackUrl = "")
|
||||
=> new()
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
OpsAlertTelegramEnabled = telegramEnabled,
|
||||
OpsAlertTelegramChatId = telegramChat,
|
||||
OpsAlertTeamsEnabled = teamsEnabled,
|
||||
OpsAlertTeamsWebhookUrl = teamsUrl,
|
||||
OpsAlertSlackEnabled = slackEnabled,
|
||||
OpsAlertSlackWebhookUrl = slackUrl,
|
||||
};
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
|
||||
|
||||
private static OpsAlertRule Rule(string eventType, params string[] channelIds)
|
||||
=> new()
|
||||
{
|
||||
EventType = eventType,
|
||||
MinSeverity = OpsEventSeverity.Info,
|
||||
Enabled = true,
|
||||
NotifyChannelIds = channelIds.Length == 0 ? null : channelIds.ToList(),
|
||||
};
|
||||
|
||||
private static OpsAlertRule PayloadRule(string eventType, params string[] channelIds)
|
||||
{
|
||||
var rule = Rule(eventType, channelIds);
|
||||
rule.IncludePayload = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>Channels that receive the baseline alert (no payload) - the default for every rule.</summary>
|
||||
private static IEnumerable<string> Plain(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).Plain.Select(c => c.Id);
|
||||
|
||||
/// <summary>Channels that receive the event structured payload (opted in per rule).</summary>
|
||||
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
|
||||
|
||||
// ── Legacy synthesis ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
|
||||
{
|
||||
var channels = LegacyConfig().GetOpsNotificationChannels();
|
||||
|
||||
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
|
||||
var telegram = channels[0];
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
|
||||
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
|
||||
Assert.Equal("-1003785642894", telegram.Url);
|
||||
Assert.True(telegram.Enabled);
|
||||
|
||||
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
|
||||
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
|
||||
{
|
||||
// A configured-but-disabled provider must stay disabled, or the migration would start
|
||||
// sending to a destination the operator had deliberately switched off.
|
||||
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
|
||||
|
||||
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
|
||||
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
|
||||
{
|
||||
var config = LegacyConfig();
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
var channels = config.GetOpsNotificationChannels();
|
||||
|
||||
Assert.Single(channels);
|
||||
Assert.Equal("sales", channels[0].Id);
|
||||
}
|
||||
|
||||
// ── Rule → channel routing ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_RuleWithoutChannelIds_HitsEveryEnabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("a"), Channel("b") };
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, Plain(new[] { Rule("X") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_RuleWithChannelIds_HitsOnlyThose()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_NeverSendsToADisabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales", enabled: false) };
|
||||
|
||||
Assert.Empty(Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_UnknownChannelIdDoesNotFallBackToBroadcast()
|
||||
{
|
||||
// A rule whose only channel was deleted must go nowhere. Falling back to "all" would
|
||||
// silently leak e.g. a sales-only event into the operator push channel.
|
||||
var channels = new[] { Channel("push") };
|
||||
|
||||
Assert.Empty(Plain(new[] { Rule("X", "deleted-channel") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_MultipleRules_UnionOfChannelsWithoutDuplicates()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales"), Channel("audit") };
|
||||
|
||||
Assert.Equal(new[] { "sales", "audit" },
|
||||
Plain(new[] { Rule("X", "sales"), Rule("X", "sales", "audit") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OneBroadcastRuleWins()
|
||||
{
|
||||
// If any matching rule is unrestricted, the union is everything - a narrower sibling rule
|
||||
// cannot shrink an existing broadcast rule reach.
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(2, Plain(new[] { Rule("X", "sales"), Rule("X") }, channels).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_ChannelIdMatchIsCaseInsensitive()
|
||||
{
|
||||
var channels = new[] { Channel("Sales") };
|
||||
|
||||
Assert.Single(Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
// ── Payload opt-in ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_PayloadIsOffUnlessTheRuleOptsIn()
|
||||
{
|
||||
// The default information level of every pre-existing rule: category, event, severity,
|
||||
// tenant id. Nothing from the event payload.
|
||||
var channels = new[] { Channel("push") };
|
||||
|
||||
Assert.Empty(WithPayload(new[] { Rule("X") }, channels));
|
||||
Assert.Equal(new[] { "push" }, Plain(new[] { Rule("X") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OptedInRuleSendsPayloadToItsChannelsOnly()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
var rules = new[] { Rule("X", "push"), PayloadRule("X", "sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
|
||||
Assert.Equal(new[] { "push" }, Plain(rules, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_ChannelInBothGroupsGetsThePayloadOnceOnly()
|
||||
{
|
||||
// One message per channel, and the explicit opt-in must not be cancelled by an
|
||||
// unrelated sibling rule that happens to target the same channel.
|
||||
var channels = new[] { Channel("sales") };
|
||||
var rules = new[] { Rule("X", "sales"), PayloadRule("X", "sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
|
||||
Assert.Empty(Plain(rules, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OptedInBroadcastRuleReachesEveryEnabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(new[] { "push", "sales" }, WithPayload(new[] { PayloadRule("X") }, channels));
|
||||
Assert.Empty(Plain(new[] { PayloadRule("X") }, channels));
|
||||
}
|
||||
|
||||
// ── Redaction ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopyForReader_RedactsOpsChannelDestinationsButKeepsStructure()
|
||||
{
|
||||
var config = new AdminConfiguration { UpdatedBy = "t" };
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel
|
||||
{
|
||||
Id = "sales", Name = "Sales", ProviderType = 20,
|
||||
Url = "https://sales.example/hook", SigningSecret = "s3cr3t-signing-key-value",
|
||||
Enabled = true,
|
||||
},
|
||||
});
|
||||
|
||||
var redacted = NotificationChannel.ParseList(
|
||||
config.RedactedCopyForReader().OpsNotificationChannelsJson).Single();
|
||||
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.Url);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.SigningSecret);
|
||||
Assert.Equal("Sales", redacted.Name); // structure stays readable
|
||||
Assert.Equal(20, redacted.ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_RestoresSentinelDestinationById()
|
||||
{
|
||||
var stored = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel { Id = "sales", Name = "Sales", ProviderType = 20, Url = "https://sales.example/hook", Enabled = true },
|
||||
});
|
||||
var roundTripped = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel { Id = "sales", Name = "Sales renamed", ProviderType = 20, Url = Constants.RedactedSecretPlaceholder, Enabled = true },
|
||||
});
|
||||
|
||||
var restored = NotificationChannel.ParseList(
|
||||
NotificationChannel.RestoreRedactedList(roundTripped, stored)).Single();
|
||||
|
||||
Assert.Equal("https://sales.example/hook", restored.Url);
|
||||
Assert.Equal("Sales renamed", restored.Name); // non-secret edits survive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_WholeStringSentinelKeepsStoredList()
|
||||
{
|
||||
var stored = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
Assert.Equal(stored, NotificationChannel.RestoreRedactedList(Constants.RedactedSecretPlaceholder, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_PassesThroughWhenNoSentinelPresent()
|
||||
{
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
Assert.Equal(candidate, NotificationChannel.RestoreRedactedList(candidate, ""));
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>
|
||||
{
|
||||
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
|
||||
});
|
||||
@@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
var members = new List<TenantAdminEntity>();
|
||||
var members = new List<TenantAdminRow>();
|
||||
if (enabledMember)
|
||||
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
|
||||
return admins;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Progress Portal success responses
|
||||
/// (Functions/Progress/ProgressPortalFunction.cs). Each case serializes the OLD anonymous
|
||||
/// literal (copied verbatim from the pre-migration code) against the NEW typed DTO with the
|
||||
/// same values via <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
|
||||
/// </summary>
|
||||
public class ProgressWireParityTests
|
||||
{
|
||||
private static SessionSummary MakeSessionSummary() => new SessionSummary
|
||||
{
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
SerialNumber = "SN-CONTOSO-0042",
|
||||
DeviceName = "CONTOSO-LT-0042",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 5440",
|
||||
StartedAt = new DateTime(2026, 8, 30, 9, 15, 0, DateTimeKind.Utc),
|
||||
CompletedAt = null,
|
||||
CurrentPhase = 3,
|
||||
CurrentPhaseDetail = "Device setup",
|
||||
Status = SessionStatus.InProgress,
|
||||
FailureReason = string.Empty
|
||||
};
|
||||
|
||||
private static List<EnrollmentEvent> MakeEvents() => new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
Timestamp = new DateTime(2026, 8, 30, 9, 16, 30, DateTimeKind.Utc),
|
||||
EventType = "phase_transition"
|
||||
},
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
Timestamp = new DateTime(2026, 8, 30, 9, 18, 5, DateTimeKind.Utc),
|
||||
EventType = "app_install_start"
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/progress/sessions/lookup — { success, found, session } with a matched session.
|
||||
[Fact]
|
||||
public void ProgressLookupSession_match_found()
|
||||
{
|
||||
var match = MakeSessionSummary();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
found = match != null,
|
||||
session = match
|
||||
},
|
||||
new ProgressLookupSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Found = match != null,
|
||||
Session = match
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/progress/sessions/lookup — no match: session is null and the key must vanish
|
||||
// identically on both sides (WhenWritingNull).
|
||||
[Fact]
|
||||
public void ProgressLookupSession_no_match_omits_session_key()
|
||||
{
|
||||
SessionSummary? match = null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
found = match != null,
|
||||
session = match
|
||||
},
|
||||
new ProgressLookupSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Found = match != null,
|
||||
Session = match
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/progress/sessions/{sessionId}/events — { success, sessionId, count, events }.
|
||||
[Fact]
|
||||
public void ProgressGetSessionEvents_success_with_events()
|
||||
{
|
||||
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
|
||||
var events = MakeEvents();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = sessionId,
|
||||
count = events.Count,
|
||||
events = events
|
||||
},
|
||||
new ProgressGetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
|
||||
// Same shape with an empty event list — count 0 and an empty array on both sides.
|
||||
[Fact]
|
||||
public void ProgressGetSessionEvents_success_with_empty_list()
|
||||
{
|
||||
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
|
||||
var events = new List<EnrollmentEvent>();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = sessionId,
|
||||
count = events.Count,
|
||||
events = events
|
||||
},
|
||||
new ProgressGetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Raw folder (anonymous-object → typed-DTO migration).
|
||||
/// Each case serializes the OLD anonymous literal exactly as it stood in the pre-migration
|
||||
/// code (keys, order, compile-time value types) against the NEW DTO with the same values.
|
||||
/// Raw rows are PascalCase-verbatim table columns carried as dictionaries — the production
|
||||
/// options set PropertyNamingPolicy (camelCase) but NO DictionaryKeyPolicy, so dictionary
|
||||
/// keys must survive untouched on both sides; one case asserts that explicitly.
|
||||
/// </summary>
|
||||
public class RawWireParityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
|
||||
|
||||
private static List<IReadOnlyDictionary<string, object?>> SampleEventRows() =>
|
||||
new()
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = "d3b7f8a2-1c4e-4f5a-9b6d-0e2f3a4b5c6d_8f1e2d3c-4b5a-6789-abcd-ef0123456789",
|
||||
["RowKey"] = "0000000042",
|
||||
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 12, 34, 56, TimeSpan.Zero),
|
||||
["EventType"] = "esp_phase_change",
|
||||
["Severity"] = 1,
|
||||
["Source"] = "EspWatcher",
|
||||
["Sequence"] = 42L,
|
||||
["DataJson"] = "{\"phase\":\"DeviceSetup\"}",
|
||||
},
|
||||
};
|
||||
|
||||
// ── QueryRawEventsFunction — GET /api/raw/events + /api/global/raw/events ────────────
|
||||
// Old literal (single-session site):
|
||||
// new { tenantId, count = filtered.Count,
|
||||
// events = RawEntityProjection.Project(filtered, fields), nextLink = singleNextLink }
|
||||
// Old literal (cross-session site) has the identical shape:
|
||||
// new { tenantId, count = events.Count,
|
||||
// events = RawEntityProjection.Project(events, fields), nextLink }
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var tenantId = "11111111-2222-3333-4444-555555555555";
|
||||
var events = SampleEventRows();
|
||||
var nextLink = "/api/raw/events?pageSize=50&continuation=abc123";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = events.Count, events, nextLink },
|
||||
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_null_tenantId_and_nextLink_keys_vanish_identically()
|
||||
{
|
||||
// Global scope without tenantId filter (cross-tenant) and last page: both keys
|
||||
// disappear under WhenWritingNull — before and after the migration.
|
||||
var tenantId = (string?)null;
|
||||
var events = new List<IReadOnlyDictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = events.Count, events, nextLink },
|
||||
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_dictionary_row_keys_stay_pascalcase_verbatim()
|
||||
{
|
||||
var events = SampleEventRows();
|
||||
var json = JsonSerializer.Serialize(
|
||||
new QueryRawEventsResponse { TenantId = "t", Count = events.Count, Events = events, NextLink = null },
|
||||
WireOptions);
|
||||
|
||||
// Envelope properties are camelCased by PropertyNamingPolicy...
|
||||
Assert.Contains("\"tenantId\"", json);
|
||||
Assert.Contains("\"events\"", json);
|
||||
// ...but the raw row keys are dictionary keys: no DictionaryKeyPolicy is set, so
|
||||
// they must remain PascalCase-verbatim stored column names.
|
||||
Assert.Contains("\"PartitionKey\"", json);
|
||||
Assert.Contains("\"RowKey\"", json);
|
||||
Assert.Contains("\"EventType\"", json);
|
||||
Assert.Contains("\"DataJson\"", json);
|
||||
Assert.DoesNotContain("\"partitionKey\"", json);
|
||||
Assert.DoesNotContain("\"eventType\"", json);
|
||||
}
|
||||
|
||||
// ── QueryRawSessionsFunction — GET /api/raw/sessions + /api/global/raw/sessions ──────
|
||||
// Old literal:
|
||||
// new { tenantId, count = page.Items.Count, sessions = sessionsPayload, nextLink }
|
||||
|
||||
[Fact]
|
||||
public void QueryRawSessions_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var tenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
var sessions = new List<IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
["RowKey"] = "9c8b7a65-4321-0fed-cba9-876543210fed",
|
||||
["Status"] = "InProgress",
|
||||
["SerialNumber"] = "VMware-42 00 11 22 33 44 55 66",
|
||||
["AgentVersion"] = "2.14.0.0",
|
||||
["DeviceName"] = "DESKTOP-CONTOSO1",
|
||||
},
|
||||
};
|
||||
var nextLink = "/api/raw/sessions?pageSize=25&continuation=xyz789";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = sessions.Count, sessions, nextLink },
|
||||
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawSessions_null_tenantId_and_nextLink_keys_vanish_identically()
|
||||
{
|
||||
var tenantId = (string?)null;
|
||||
var sessions = new List<IReadOnlyDictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = sessions.Count, sessions, nextLink },
|
||||
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
|
||||
}
|
||||
|
||||
// ── TableQueryFunction.ListTables — GET /api/global/raw/tables ───────────────────────
|
||||
// Old literal: new { count = tables.Count, tables }
|
||||
|
||||
[Fact]
|
||||
public void ListRawTables_matches_old_anonymous_shape()
|
||||
{
|
||||
var tables = new List<string> { "Sessions", "SessionsIndex", "TenantConfiguration" };
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { count = tables.Count, tables },
|
||||
new ListRawTablesResponse { Count = tables.Count, Tables = tables });
|
||||
}
|
||||
|
||||
// ── TableQueryFunction.QueryTable — GET /api/global/raw/tables/{tableName} ───────────
|
||||
// Old literal: new { table = actualTableName, count = entities.Count, entities, nextLink }
|
||||
// (entities is List<Dictionary<string, object?>> at the call site.)
|
||||
|
||||
[Fact]
|
||||
public void QueryRawTable_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var table = "SessionsIndex";
|
||||
var entities = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
["PartitionKey"] = "11111111-2222-3333-4444-555555555555",
|
||||
["RowKey"] = "8f1e2d3c-4b5a-6789-abcd-ef0123456789",
|
||||
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 8, 0, 0, TimeSpan.Zero),
|
||||
["Status"] = "Succeeded",
|
||||
["RebootCount"] = 2,
|
||||
},
|
||||
};
|
||||
var nextLink = "/api/global/raw/tables/SessionsIndex?pageSize=100&continuation=tok456";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { table, count = entities.Count, entities, nextLink },
|
||||
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawTable_null_nextLink_key_vanishes_identically()
|
||||
{
|
||||
var table = "OpsEvents";
|
||||
var entities = new List<Dictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { table, count = entities.Count, entities, nextLink },
|
||||
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
|
||||
}
|
||||
}
|
||||
@@ -121,13 +121,7 @@ public class ReportAgentErrorFunctionTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
OpsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Reports folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it appeared at the
|
||||
/// call site (independent fixture, copied from the pre-migration code) and the NEW typed DTO
|
||||
/// with the same values, asserting ordinally identical JSON via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
|
||||
/// </summary>
|
||||
public class ReportsWireParityTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 8, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
// =========================================================================
|
||||
// GetDeviceNotRegisteredFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DeviceNotRegistered_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices.";
|
||||
|
||||
// Old anonymous item shape from BuildAggregatedResult (pre-migration), in a List<object>
|
||||
// exactly as the old code produced it.
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
serialNumber = "GM18NHV3",
|
||||
manufacturer = "Lenovo",
|
||||
model = "ThinkPad T14",
|
||||
isCloudPc = true,
|
||||
attemptCount = 3,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddHours(2)
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 3,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new DeviceNotRegisteredResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<DeviceNotRegisteredItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SerialNumber = "GM18NHV3",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkPad T14",
|
||||
IsCloudPc = true,
|
||||
AttemptCount = 3,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddHours(2)
|
||||
}
|
||||
},
|
||||
TotalRawReports = 3,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetHardwareRejectedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void HardwareRejected_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Manufacturer, model, and serial number values are self-reported by devices.";
|
||||
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
manufacturer = "Dell Inc.",
|
||||
model = "Latitude 5440",
|
||||
attemptCount = 4,
|
||||
uniqueSerials = 2,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddDays(1),
|
||||
sampleSerialNumbers = new List<string> { "SN1", "SN2" }
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 4,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new HardwareRejectedResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<HardwareRejectedItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Manufacturer = "Dell Inc.",
|
||||
Model = "Latitude 5440",
|
||||
AttemptCount = 4,
|
||||
UniqueSerials = 2,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddDays(1),
|
||||
SampleSerialNumbers = new List<string> { "SN1", "SN2" }
|
||||
}
|
||||
},
|
||||
TotalRawReports = 4,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetTpmPssUnsupportedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void TpmPssUnsupported_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices.";
|
||||
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
serialNumber = "SN1",
|
||||
manufacturer = "Lenovo",
|
||||
model = "ThinkCentre M720q",
|
||||
attemptCount = 3,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddHours(2)
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 3,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new TpmPssUnsupportedResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<TpmPssUnsupportedItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SerialNumber = "SN1",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkCentre M720q",
|
||||
AttemptCount = 3,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddHours(2)
|
||||
}
|
||||
},
|
||||
TotalRawReports = 3,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetSessionReportsFunction — non-paged { success, count, reports } and
|
||||
// paged { success, count, reports, nextLink } — one DTO (SessionReportListResponse)
|
||||
// =========================================================================
|
||||
|
||||
private static List<SessionReportMetadata> MakeReports() =>
|
||||
new()
|
||||
{
|
||||
new SessionReportMetadata
|
||||
{
|
||||
ReportId = "5f0d2c9a-1b3e-4f6a-8c7d-9e0f1a2b3c4d",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Comment = "ESP stuck at device setup",
|
||||
Email = "admin@contoso.com",
|
||||
BlobName = "report_5f0d2c9a.zip",
|
||||
SubmittedBy = "admin@contoso.com",
|
||||
SubmittedAt = T0,
|
||||
AdminNote = "",
|
||||
ReportType = ReportTypes.Session,
|
||||
DiagnosticsBlobName = null,
|
||||
DiagnosticsCopyStatus = null
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_nonPaged_matches_old_anonymous_shape_without_nextLink_key()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
|
||||
// Old non-paged literal had NO nextLink property; the typed DTO leaves NextLink null
|
||||
// and WhenWritingNull must drop the key so both sides stay identical.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, count = reports.Count, reports },
|
||||
new SessionReportListResponse { Success = true, Count = reports.Count, Reports = reports });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_paged_with_nextLink_matches_old_anonymous_shape()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
var nextLink = "/api/global/session-reports?pageSize=50&continuation=abc123";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports,
|
||||
nextLink,
|
||||
},
|
||||
new SessionReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_paged_lastPage_nextLink_null_drops_key_on_both_sides()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
string? nextLink = null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports,
|
||||
nextLink,
|
||||
},
|
||||
new SessionReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetDistressReportsFunction — { success, count, reports }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DistressReports_matches_old_anonymous_shape()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
ErrorType = "DeviceNotRegistered",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkPad T14",
|
||||
SerialNumber = "GM18NHV3",
|
||||
AgentVersion = "2.4.1.0",
|
||||
HttpStatusCode = 403,
|
||||
Message = "Device not found in registry",
|
||||
AgentTimestamp = T0,
|
||||
IngestedAt = T0.AddSeconds(5),
|
||||
SourceIp = "203.0.113.10",
|
||||
IsCloudPc = false
|
||||
// Cert-context fields left null — WhenWritingNull drops them identically
|
||||
// on both sides (same DistressReportEntry class serializes the items).
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports
|
||||
},
|
||||
new DistressReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetSessionReportDownloadUrlFunction — { success, downloadUrl }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SessionReportDownloadUrl_matches_old_anonymous_shape()
|
||||
{
|
||||
var downloadUrl = "https://storage.example.invalid/session-reports/report_5f0d2c9a.zip?sv=2024-11-04&sig=abc";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, downloadUrl },
|
||||
new SessionReportDownloadUrlResponse { Success = true, DownloadUrl = downloadUrl });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UpdateSessionReportNoteFunction — { success } (reuses SuccessOnlyResponse)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void UpdateSessionReportNote_success_matches_old_anonymous_shape()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Rules function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class RulesWireParityTests
|
||||
{
|
||||
// ---- GetAnalyzeRules / GetGlobalAnalyzeRules -----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeRuleListResponse_matches_the_rules_listing_shape()
|
||||
{
|
||||
var rules = new List<AnalyzeRule>
|
||||
{
|
||||
new AnalyzeRule { RuleId = "ANALYZE-NET-001" },
|
||||
new AnalyzeRule { RuleId = "ANALYZE-APP-017" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new AnalyzeRuleListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
// ---- GetGatherRules / GetGlobalGatherRules -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GatherRuleListResponse_matches_the_rules_listing_shape()
|
||||
{
|
||||
var rules = new List<GatherRule>
|
||||
{
|
||||
new GatherRule { RuleId = "GATHER-LOG-001", Author = "Contoso Admin" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new GatherRuleListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
// ---- GetImeLogPatterns ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImeLogPatternListResponse_matches_the_patterns_listing_shape()
|
||||
{
|
||||
var patterns = new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern { PatternId = "IME-PS-AGENT-OUTPUT" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, patterns },
|
||||
new ImeLogPatternListResponse { Success = true, Patterns = patterns });
|
||||
}
|
||||
|
||||
// ---- Create/Update/Delete rule + UpdateImeLogPattern (dual success/message sites) ----
|
||||
|
||||
[Fact]
|
||||
public void Rule_mutation_acknowledgements_match_SuccessMessageResponse_on_both_branches()
|
||||
{
|
||||
foreach (var success in new[] { true, false })
|
||||
{
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule created" : "Failed to create rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule created" : "Failed to create rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule updated" : "Failed to update rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule deleted" : "Failed to delete rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule deleted" : "Failed to delete rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Global pattern updated" : "Failed to update global pattern" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Global pattern updated" : "Failed to update global pattern" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreateAnalyzeRuleFromTemplate ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CreateAnalyzeRuleFromTemplateResponse_matches_the_created_rule_shape()
|
||||
{
|
||||
var newRule = new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-0001" };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rule = newRule, message = "Custom rule created from template" },
|
||||
new CreateAnalyzeRuleFromTemplateResponse { Success = true, Rule = newRule, Message = "Custom rule created from template" });
|
||||
}
|
||||
|
||||
// ---- DryRunAnalyzeRule ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DryRunAnalyzeRuleResponse_matches_the_dry_run_trace_shape()
|
||||
{
|
||||
var sessionId = "7c1e2a90-1111-4b7c-a1d2-52f3bbbb0001";
|
||||
var result = new RuleDryRun
|
||||
{
|
||||
Verdict = RuleDryRunVerdict.Fired,
|
||||
EventCount = 42,
|
||||
BaseConfidence = 50,
|
||||
FinalConfidence = 80,
|
||||
ConfidenceThreshold = 60,
|
||||
WouldMarkSessionAsFailed = false,
|
||||
MatchedConditions = new Dictionary<string, object>
|
||||
{
|
||||
["timeout_seen"] = new { eventId = "evt-0001", value = "timeout" },
|
||||
},
|
||||
};
|
||||
result.Conditions.Add(new RuleDryRunCondition
|
||||
{
|
||||
Signal = "timeout_seen",
|
||||
Source = "event_data",
|
||||
EventType = "app_install_failed",
|
||||
Required = true,
|
||||
Matched = true,
|
||||
Evidence = new Dictionary<string, object> { ["field"] = "errorCode" },
|
||||
MatchingEventCount = 3,
|
||||
});
|
||||
result.ConfidenceFactors.Add(new RuleDryRunFactor
|
||||
{
|
||||
Signal = "timeout_seen",
|
||||
Condition = "count >= 2",
|
||||
Weight = 30,
|
||||
Matched = true,
|
||||
});
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, result },
|
||||
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
|
||||
}
|
||||
|
||||
// ---- GetRuleResults ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRuleResultsResponse_matches_the_analysis_shape_with_persist_failures()
|
||||
{
|
||||
var sessionId = "7c1e2a90-2222-4b7c-a1d2-52f3bbbb0002";
|
||||
var results = new List<RuleResult>
|
||||
{
|
||||
new RuleResult
|
||||
{
|
||||
ResultId = "9d0f1a2b-0001-4e0e-8888-52f3bbbb0003",
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
RuleId = "ANALYZE-NET-001",
|
||||
RuleTitle = "Network timeout during ESP",
|
||||
Severity = "critical",
|
||||
Category = "network",
|
||||
ConfidenceScore = 90,
|
||||
Explanation = "sample",
|
||||
DetectedAt = new DateTime(2026, 8, 30, 11, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new RuleResult
|
||||
{
|
||||
ResultId = "9d0f1a2b-0002-4e0e-8888-52f3bbbb0004",
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
RuleId = "ANALYZE-APP-017",
|
||||
RuleTitle = "Resolved finding",
|
||||
Severity = "warning",
|
||||
Category = "apps",
|
||||
ConfidenceScore = 70,
|
||||
Explanation = "sample",
|
||||
DetectedAt = new DateTime(2026, 8, 30, 11, 5, 0, DateTimeKind.Utc),
|
||||
ResolvedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
|
||||
var persistFailureRuleIds = new List<string> { "ANALYZE-NET-001" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = persistFailureRuleIds.Count == 0,
|
||||
sessionId,
|
||||
results,
|
||||
totalIssues = openResults.Count,
|
||||
criticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
highCount = openResults.Count(r => r.Severity == "high"),
|
||||
warningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
persistFailureCount = persistFailureRuleIds.Count,
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
},
|
||||
new GetRuleResultsResponse
|
||||
{
|
||||
Success = persistFailureRuleIds.Count == 0,
|
||||
SessionId = sessionId,
|
||||
Results = results,
|
||||
TotalIssues = openResults.Count,
|
||||
CriticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
HighCount = openResults.Count(r => r.Severity == "high"),
|
||||
WarningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
PersistFailureCount = persistFailureRuleIds.Count,
|
||||
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRuleResultsResponse_omits_persistFailureRuleIds_on_the_happy_path()
|
||||
{
|
||||
var sessionId = "7c1e2a90-3333-4b7c-a1d2-52f3bbbb0005";
|
||||
var results = new List<RuleResult>();
|
||||
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
|
||||
var persistFailureRuleIds = new List<string>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = persistFailureRuleIds.Count == 0,
|
||||
sessionId,
|
||||
results,
|
||||
totalIssues = openResults.Count,
|
||||
criticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
highCount = openResults.Count(r => r.Severity == "high"),
|
||||
warningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
persistFailureCount = persistFailureRuleIds.Count,
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
},
|
||||
new GetRuleResultsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Results = results,
|
||||
TotalIssues = 0,
|
||||
CriticalCount = 0,
|
||||
HighCount = 0,
|
||||
WarningCount = 0,
|
||||
PersistFailureCount = 0,
|
||||
PersistFailureRuleIds = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ReseedImeLogPatterns ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReseedImeLogPatternsResponse_matches_the_reseed_summary_shape()
|
||||
{
|
||||
var deleted = 79;
|
||||
var written = 81;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
|
||||
deleted,
|
||||
written
|
||||
},
|
||||
new ReseedImeLogPatternsResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
|
||||
Deleted = deleted,
|
||||
Written = written
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TestLogPattern ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestLogPatternResponse_matches_the_pattern_test_shape()
|
||||
{
|
||||
var isTextMode = false;
|
||||
var result = new LogPatternTestResult
|
||||
{
|
||||
MatchCount = 1,
|
||||
ParseFailureCount = 0,
|
||||
TimeoutCount = 0,
|
||||
};
|
||||
result.Lines.Add(new LogPatternLineResult
|
||||
{
|
||||
LineNumber = 1,
|
||||
Outcome = "matched",
|
||||
Groups = new Dictionary<string, string> { ["level"] = "ERROR", ["message"] = "disk full" },
|
||||
MatchedText = "ERROR: disk full",
|
||||
Component = "Installer",
|
||||
CmTraceType = 3,
|
||||
Message = "ERROR: disk full while unpacking",
|
||||
});
|
||||
// no_match row: every nullable slot stays null → the keys must vanish on both sides.
|
||||
result.Lines.Add(new LogPatternLineResult
|
||||
{
|
||||
LineNumber = 2,
|
||||
Outcome = "no_match",
|
||||
});
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
|
||||
AssertParity(
|
||||
new { success = true, format = isTextMode ? "text" : "cmtrace", result },
|
||||
new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
}
|
||||
|
||||
// ---- GetPreviewWhitelist -------------------------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only
|
||||
// in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row.
|
||||
|
||||
[Fact]
|
||||
public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape()
|
||||
{
|
||||
var approved = new List<PreviewWhitelistTenantEntry>
|
||||
{
|
||||
new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" },
|
||||
new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" },
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved });
|
||||
Assert.Equal(
|
||||
"{\"tenants\":["
|
||||
+ "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"},"
|
||||
+ "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("approvedAt", json);
|
||||
}
|
||||
|
||||
// ---- GetPreviewNotificationEmail -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetPreviewNotificationEmailResponse_matches_the_coalesced_email_shape()
|
||||
{
|
||||
string? email = "ops@fabrikam.com";
|
||||
AssertParity(
|
||||
new { email = email ?? "" },
|
||||
new GetPreviewNotificationEmailResponse { Email = email ?? "" });
|
||||
|
||||
// No stored address: the site coalesces to "" — the key stays present.
|
||||
email = null;
|
||||
AssertParity(
|
||||
new { email = email ?? "" },
|
||||
new GetPreviewNotificationEmailResponse { Email = "" });
|
||||
}
|
||||
|
||||
// ---- GetAllPreviewNotificationEmails -------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetAllPreviewNotificationEmailsResponse_matches_the_address_map_shape()
|
||||
{
|
||||
var emails = new Dictionary<string, string>
|
||||
{
|
||||
["6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d"] = "ops@contoso.com",
|
||||
["0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01"] = "it@fabrikam.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { count = emails.Count, emails },
|
||||
new GetAllPreviewNotificationEmailsResponse { Count = emails.Count, Emails = emails });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -231,7 +231,10 @@ public class SessionAnnotationFunctionsTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
// Production wire settings (camelCase, absent-when-null, string enums) — assertions
|
||||
// must run against the shape the deployed worker actually serializes.
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
|
||||
|
||||
+1
-5
@@ -397,11 +397,7 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var maintRepo = new Mock<IMaintenanceRepository>();
|
||||
|
||||
@@ -659,11 +659,7 @@ public class SessionDeletionPr4cCodexFixesTests
|
||||
// backed by a noop repository — F5 tests assert CAS ordering, not the OpsEvent itself.
|
||||
var opsRepoStub = new Mock<IOpsEventRepository>();
|
||||
opsRepoStub.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
var alertDispatchStub = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatchStub = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsServiceStub = new OpsEventService(opsRepoStub.Object, NullLogger<OpsEventService>.Instance, alertDispatchStub);
|
||||
|
||||
// PR-B Codex F4 follow-up: worker now reads DeletionProgress on the poison path.
|
||||
|
||||
@@ -679,11 +679,7 @@ public class SessionDeletionWorkerTests
|
||||
lock (OpsEventCalls) OpsEventCalls.Add(CapturedOpsEvent.From(e));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Sut = new SessionDeletionWorker(
|
||||
|
||||
@@ -46,12 +46,7 @@ public class SessionOwnerBindingObserverTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Sut = new SessionOwnerBindingObserver(
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Sessions function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class SessionsWireParityTests
|
||||
{
|
||||
private static SessionSummary SampleSummary(string sessionId) => new SessionSummary
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
StartedAt = new DateTime(2026, 8, 30, 11, 15, 0, DateTimeKind.Utc),
|
||||
CurrentPhase = 3,
|
||||
CurrentPhaseDetail = "Device Setup",
|
||||
Status = SessionStatus.InProgress,
|
||||
FailureReason = string.Empty,
|
||||
};
|
||||
|
||||
// ---- GetSessions / GetAllSessions / SearchSessionsByCve / SearchSessionsByEvent ------
|
||||
|
||||
[Fact]
|
||||
public void SessionListResponse_matches_the_paged_listing_shape()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> items = new List<SessionSummary>
|
||||
{
|
||||
SampleSummary("0b6f7a37-1111-4d61-9c93-0aa111111111"),
|
||||
SampleSummary("0b6f7a37-2222-4d61-9c93-0aa222222222"),
|
||||
};
|
||||
string? nextLink = "/api/sessions?pageSize=2&continuation=abc";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
sessions = items,
|
||||
nextLink,
|
||||
},
|
||||
new SessionListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Sessions = items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionListResponse_omits_nextLink_on_the_last_page()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> items = new List<SessionSummary>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
sessions = items,
|
||||
nextLink,
|
||||
},
|
||||
new SessionListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Sessions = items,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SearchSessions ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SearchSessionsResponse_matches_the_unprojected_shape()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> pageItems = new List<SessionSummary>
|
||||
{
|
||||
SampleSummary("3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001"),
|
||||
};
|
||||
// Old site: `object sessionsPayload` — either the full items or the fields= projection.
|
||||
object sessionsPayload = pageItems;
|
||||
string? nextLink = "/api/search/sessions?pageSize=1&continuation=xyz";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
sessions = sessionsPayload,
|
||||
nextLink,
|
||||
},
|
||||
new SearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Sessions = pageItems,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchSessionsResponse_matches_the_fields_projected_shape_and_omits_null_nextLink()
|
||||
{
|
||||
var projected = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sessionId"] = "3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
["status"] = "Failed",
|
||||
["startedAt"] = new DateTime(2026, 8, 29, 7, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
object sessionsPayload = projected;
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
sessions = sessionsPayload,
|
||||
nextLink,
|
||||
},
|
||||
new SearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Sessions = projected,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionStats / GetAllSessionStats --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SessionStatsResponse_matches_the_stats_shape()
|
||||
{
|
||||
var stats = new SessionStats
|
||||
{
|
||||
Days = 7,
|
||||
ActiveCount = 3,
|
||||
TotalLastNDays = 42,
|
||||
SucceededLastNDays = 30,
|
||||
FailedLastNDays = 5,
|
||||
IncompleteLastNDays = 7,
|
||||
SuccessRatePct = 86,
|
||||
AvgDurationMinutes = 55,
|
||||
MedianDurationMinutes = 48,
|
||||
P90DurationMinutes = 92,
|
||||
TotalToday = 4,
|
||||
FailedToday = 1,
|
||||
ComputedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, stats },
|
||||
new SessionStatsResponse { Success = true, Stats = stats });
|
||||
}
|
||||
|
||||
// ---- GetSession ----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionResponse_matches_the_single_session_shape()
|
||||
{
|
||||
var session = SampleSummary("9d2f5f70-cccc-4f31-9f1e-52f3aaaa0003");
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
session
|
||||
},
|
||||
new GetSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Session = session
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionEvents (unpaginated + paginated) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_matches_the_unpaginated_shape_without_a_nextLink_key()
|
||||
{
|
||||
var sessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004";
|
||||
// EventFieldProjection.Project returns List<object> (full events or dictionary projections).
|
||||
var events = new List<object>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "evt-0001",
|
||||
SessionId = sessionId,
|
||||
EventType = "phase_transition",
|
||||
Timestamp = new DateTime(2026, 8, 30, 11, 20, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new Dictionary<string, object?> { ["eventType"] = "app_install_start" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
// NextLink stays null → the key is absent, exactly like the old literal
|
||||
// that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_matches_the_paginated_shape_with_a_nextLink()
|
||||
{
|
||||
var sessionId = "5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005";
|
||||
var events = new List<object>();
|
||||
string? nextLink = "/api/sessions/5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005/events?pageSize=100&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
nextLink,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_omits_a_null_nextLink_on_the_paginated_path()
|
||||
{
|
||||
var sessionId = "5c8e1b20-ffff-49a2-8bd1-52f3aaaa0006";
|
||||
var events = new List<object>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
nextLink,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionSignals ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionSignalsResponse_matches_the_signal_log_shape()
|
||||
{
|
||||
var sessionId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new SignalRecord
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = sessionId,
|
||||
SessionSignalOrdinal = 1,
|
||||
SessionTraceOrdinal = 5,
|
||||
Kind = "EspPhaseObserved",
|
||||
KindSchemaVersion = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 25, 0, DateTimeKind.Utc),
|
||||
SourceOrigin = "agent",
|
||||
PayloadJson = "{\"phase\":\"DeviceSetup\"}",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = signals.Count,
|
||||
truncated = false,
|
||||
signals,
|
||||
},
|
||||
new GetSessionSignalsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = signals.Count,
|
||||
Truncated = false,
|
||||
Signals = signals,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionReducerVerification ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionReducerVerificationResponse_matches_the_report_shape()
|
||||
{
|
||||
var report = new ReducerVerificationReport
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "8bb31d22-0002-4b7c-a1d2-52f3aaaa0008",
|
||||
SignalCount = 12,
|
||||
TransitionCount = 11,
|
||||
StoredReducerVersion = "r-2026.08",
|
||||
CurrentReducerVersion = "r-2026.08",
|
||||
SignalOrdinalsContiguous = true,
|
||||
SignalOrdinalFirst = 1,
|
||||
SignalOrdinalLast = 12,
|
||||
StepIndicesContiguous = true,
|
||||
StepIndexFirst = 0,
|
||||
StepIndexLast = 10,
|
||||
SemanticReplayPerformed = true,
|
||||
SemanticReplayFinalStageMatches = true,
|
||||
ReplayedFinalStage = "Completed",
|
||||
Issues = new List<VerificationIssue>
|
||||
{
|
||||
new VerificationIssue { Severity = "Info", Kind = "empty_session", Message = "sample" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
report,
|
||||
},
|
||||
new GetSessionReducerVerificationResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Report = report,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDecisionGraph ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDecisionGraphResponse_matches_the_graph_shape()
|
||||
{
|
||||
var projection = new DecisionGraphProjection
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "9cc42e33-0003-4b7c-a1d2-52f3aaaa0009",
|
||||
Nodes = new List<DecisionGraphNode>
|
||||
{
|
||||
new DecisionGraphNode { Id = "EspInProgress", IsTerminal = false, VisitCount = 1 },
|
||||
},
|
||||
Edges = new List<DecisionGraphEdge>
|
||||
{
|
||||
new DecisionGraphEdge
|
||||
{
|
||||
StepIndex = 0,
|
||||
FromStage = "Registered",
|
||||
ToStage = "EspInProgress",
|
||||
Trigger = "EspPhaseObserved",
|
||||
Taken = true,
|
||||
SignalOrdinalRef = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 26, 0, DateTimeKind.Utc),
|
||||
},
|
||||
},
|
||||
ReducerVersion = "r-2026.08",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
graph = projection,
|
||||
},
|
||||
new GetSessionDecisionGraphResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Graph = projection,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletionsList ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionsListResponse_matches_the_deletion_listing_shape()
|
||||
{
|
||||
var state = "Queued";
|
||||
int? strandedSinceMinutes = 30;
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var sessionId = "add53f44-0004-4b7c-a1d2-52f3aaaa0010";
|
||||
var deletionState = "Queued";
|
||||
var manifestId = "m-20260830-01";
|
||||
var timestamp = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc).ToString("o");
|
||||
var ageMinutes = 65;
|
||||
|
||||
var anonymousSessions = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
sessionId,
|
||||
deletionState,
|
||||
manifestId,
|
||||
timestamp,
|
||||
ageMinutes,
|
||||
},
|
||||
};
|
||||
var typedSessions = new List<SessionDeletionListItem>
|
||||
{
|
||||
new SessionDeletionListItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
DeletionState = deletionState,
|
||||
ManifestId = manifestId,
|
||||
Timestamp = timestamp,
|
||||
AgeMinutes = ageMinutes,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
state,
|
||||
strandedSinceMinutes,
|
||||
count = anonymousSessions.Count,
|
||||
sessions = anonymousSessions,
|
||||
},
|
||||
new GetSessionDeletionsListResponse
|
||||
{
|
||||
Success = true,
|
||||
State = state,
|
||||
StrandedSinceMinutes = strandedSinceMinutes,
|
||||
Count = typedSessions.Count,
|
||||
Sessions = typedSessions,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionsListResponse_omits_a_null_strandedSinceMinutes()
|
||||
{
|
||||
var state = "Poisoned";
|
||||
int? strandedSinceMinutes = null;
|
||||
var sessions = new List<object>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
state,
|
||||
strandedSinceMinutes,
|
||||
count = sessions.Count,
|
||||
sessions,
|
||||
},
|
||||
new GetSessionDeletionsListResponse
|
||||
{
|
||||
Success = true,
|
||||
State = state,
|
||||
StrandedSinceMinutes = null,
|
||||
Count = 0,
|
||||
Sessions = new List<SessionDeletionListItem>(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantDeletionManifests ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantDeletionManifestsResponse_matches_the_manifest_tree_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
string? sessionFilter = "bee64055-0005-4b7c-a1d2-52f3aaaa0011";
|
||||
var manifestId = "m-20260829-02";
|
||||
long sizeBytes = 20480;
|
||||
var lastModifiedUtc = new DateTime(2026, 8, 29, 18, 30, 0, DateTimeKind.Utc).ToString("o");
|
||||
|
||||
var anonymousManifests = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
manifestId,
|
||||
sizeBytes,
|
||||
lastModifiedUtc,
|
||||
},
|
||||
};
|
||||
var anonymousSessions = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = sessionFilter,
|
||||
manifestCount = anonymousManifests.Count,
|
||||
latestManifestUtc = lastModifiedUtc,
|
||||
manifests = anonymousManifests,
|
||||
},
|
||||
};
|
||||
|
||||
var typedManifests = new List<TenantDeletionManifestItem>
|
||||
{
|
||||
new TenantDeletionManifestItem
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
SizeBytes = sizeBytes,
|
||||
LastModifiedUtc = lastModifiedUtc,
|
||||
},
|
||||
};
|
||||
var typedSessions = new List<TenantDeletionManifestSessionNode>
|
||||
{
|
||||
new TenantDeletionManifestSessionNode
|
||||
{
|
||||
SessionId = sessionFilter!,
|
||||
ManifestCount = typedManifests.Count,
|
||||
LatestManifestUtc = lastModifiedUtc,
|
||||
Manifests = typedManifests,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
sessionFilter,
|
||||
sessionCount = anonymousSessions.Count,
|
||||
manifestCount = 1,
|
||||
sessions = anonymousSessions,
|
||||
},
|
||||
new GetTenantDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = tenantId,
|
||||
SessionFilter = sessionFilter,
|
||||
SessionCount = typedSessions.Count,
|
||||
ManifestCount = 1,
|
||||
Sessions = typedSessions,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTenantDeletionManifestsResponse_omits_a_null_sessionFilter()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
string? sessionFilter = null;
|
||||
var sessions = new List<object>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
sessionFilter,
|
||||
sessionCount = sessions.Count,
|
||||
manifestCount = 0,
|
||||
sessions,
|
||||
},
|
||||
new GetTenantDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = tenantId,
|
||||
SessionFilter = null,
|
||||
SessionCount = 0,
|
||||
ManifestCount = 0,
|
||||
Sessions = new List<TenantDeletionManifestSessionNode>(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantsWithDeletionManifests -------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantsWithDeletionManifestsResponse_matches_the_tenant_id_listing_shape()
|
||||
{
|
||||
var sorted = new List<string>
|
||||
{
|
||||
"0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
|
||||
"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = sorted.Count,
|
||||
tenantIds = sorted,
|
||||
},
|
||||
new GetTenantsWithDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = sorted.Count,
|
||||
TenantIds = sorted,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- QuickSearchSessions -------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void QuickSearchSessionsResponse_matches_the_typeahead_shape()
|
||||
{
|
||||
var results = new List<QuickSearchResult>
|
||||
{
|
||||
new QuickSearchResult
|
||||
{
|
||||
SessionId = "cff75166-0006-4b7c-a1d2-52f3aaaa0012",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Status = SessionStatus.Succeeded,
|
||||
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
|
||||
MatchedField = "serialNumber",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = results.Count,
|
||||
results
|
||||
},
|
||||
new QuickSearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = results.Count,
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletePreview (mode=summary) ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletePreviewResponse_matches_the_summary_shape()
|
||||
{
|
||||
string? inFlightHint = "Cascade in progress (state=Running, manifestId=m-1); preview shows current data anyway.";
|
||||
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1, ["Events"] = 250 };
|
||||
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Events"] = new List<object> { new { pk = "tenant_session", rk = "0000000000000000001" } },
|
||||
};
|
||||
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Events"] = new List<DeletionRowKeySample>
|
||||
{
|
||||
new DeletionRowKeySample { Pk = "tenant_session", Rk = "0000000000000000001" },
|
||||
},
|
||||
};
|
||||
long totalRowCount = 251;
|
||||
long estimatedBytes = 40960;
|
||||
long builderDurationMs = 320;
|
||||
var schemaHash = "sha256:abcd";
|
||||
var manifestId = "m-20260830-03";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
inFlightHint,
|
||||
preflightCounts,
|
||||
sampleKeys = anonymousSampleKeys,
|
||||
estimatedRowCount = totalRowCount,
|
||||
estimatedSnapshotBytes = estimatedBytes,
|
||||
builderDurationMs,
|
||||
schemaHash,
|
||||
manifestId,
|
||||
},
|
||||
new GetSessionDeletePreviewResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
InFlightHint = inFlightHint,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = typedSampleKeys,
|
||||
EstimatedRowCount = totalRowCount,
|
||||
EstimatedSnapshotBytes = estimatedBytes,
|
||||
BuilderDurationMs = builderDurationMs,
|
||||
SchemaHash = schemaHash,
|
||||
ManifestId = manifestId,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletePreviewResponse_omits_a_null_inFlightHint()
|
||||
{
|
||||
string? inFlightHint = null;
|
||||
var preflightCounts = new Dictionary<string, int>();
|
||||
var sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
inFlightHint,
|
||||
preflightCounts,
|
||||
sampleKeys,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = -1L,
|
||||
builderDurationMs = 12L,
|
||||
schemaHash = "sha256:abcd",
|
||||
manifestId = "m-20260830-04",
|
||||
},
|
||||
new GetSessionDeletePreviewResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
InFlightHint = null,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = -1,
|
||||
BuilderDurationMs = 12,
|
||||
SchemaHash = "sha256:abcd",
|
||||
ManifestId = "m-20260830-04",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletionManifest (mode=summary) ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_matches_the_stored_summary_shape_with_progress()
|
||||
{
|
||||
var manifestId = "m-20260830-05";
|
||||
var schemaHash = "sha256:ef01";
|
||||
var snapshotSha = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1 };
|
||||
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Sessions"] = new List<object> { new { pk = "6a6a35a2", rk = "sess-1" } },
|
||||
};
|
||||
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Sessions"] = new List<DeletionRowKeySample> { new DeletionRowKeySample { Pk = "6a6a35a2", Rk = "sess-1" } },
|
||||
};
|
||||
var completedSteps = new HashSet<int> { 0, 1, 2 };
|
||||
DateTime? completedAt = new DateTime(2026, 8, 30, 12, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId,
|
||||
schemaHash,
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 1L,
|
||||
estimatedSnapshotBytes = 2048L,
|
||||
preflightCounts,
|
||||
sampleKeys = anonymousSampleKeys,
|
||||
progress = new
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
completedStepOrders = completedSteps,
|
||||
VerificationDone = true,
|
||||
TombstoneStarted = true,
|
||||
CompletedAt = completedAt,
|
||||
aggregateDecrementsApplied = 2,
|
||||
restoreReIncrementsApplied = 0,
|
||||
LastFailureType = (string?)"ResidualRows",
|
||||
LastFailureMessage = (string?)"3 residual rows observed",
|
||||
LastObservedResidualCount = (int?)3,
|
||||
LastResidualSampleJson = (string?)"[{\"pk\":\"a\",\"rk\":\"b\"}]",
|
||||
},
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = manifestId,
|
||||
SchemaHash = schemaHash,
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 1,
|
||||
EstimatedSnapshotBytes = 2048,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = typedSampleKeys,
|
||||
Progress = new SessionDeletionProgressWire
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
CompletedStepOrders = completedSteps,
|
||||
VerificationDone = true,
|
||||
TombstoneStarted = true,
|
||||
CompletedAt = completedAt,
|
||||
AggregateDecrementsApplied = 2,
|
||||
RestoreReIncrementsApplied = 0,
|
||||
LastFailureType = "ResidualRows",
|
||||
LastFailureMessage = "3 residual rows observed",
|
||||
LastObservedResidualCount = 3,
|
||||
LastResidualSampleJson = "[{\"pk\":\"a\",\"rk\":\"b\"}]",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_omits_null_slots_inside_progress()
|
||||
{
|
||||
var snapshotSha = "aa86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
var completedSteps = new HashSet<int> { 0 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId = "m-20260830-06",
|
||||
schemaHash = "sha256:ef02",
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = 512L,
|
||||
preflightCounts = new Dictionary<string, int>(),
|
||||
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
|
||||
progress = new
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
completedStepOrders = completedSteps,
|
||||
VerificationDone = false,
|
||||
TombstoneStarted = false,
|
||||
CompletedAt = (DateTime?)null,
|
||||
aggregateDecrementsApplied = 0,
|
||||
restoreReIncrementsApplied = 0,
|
||||
LastFailureType = (string?)null,
|
||||
LastFailureMessage = (string?)null,
|
||||
LastObservedResidualCount = (int?)null,
|
||||
LastResidualSampleJson = (string?)null,
|
||||
},
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = "m-20260830-06",
|
||||
SchemaHash = "sha256:ef02",
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = 512,
|
||||
PreflightCounts = new Dictionary<string, int>(),
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
Progress = new SessionDeletionProgressWire
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
CompletedStepOrders = completedSteps,
|
||||
VerificationDone = false,
|
||||
TombstoneStarted = false,
|
||||
CompletedAt = null,
|
||||
AggregateDecrementsApplied = 0,
|
||||
RestoreReIncrementsApplied = 0,
|
||||
LastFailureType = null,
|
||||
LastFailureMessage = null,
|
||||
LastObservedResidualCount = null,
|
||||
LastResidualSampleJson = null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_omits_a_null_progress_object()
|
||||
{
|
||||
var snapshotSha = "bb86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
// Old site: `progress = progress == null ? null : new { ... }` — the whole key vanishes.
|
||||
object? progress = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId = "m-20260830-07",
|
||||
schemaHash = "sha256:ef03",
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = 256L,
|
||||
preflightCounts = new Dictionary<string, int>(),
|
||||
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
|
||||
progress,
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = "m-20260830-07",
|
||||
SchemaHash = "sha256:ef03",
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = 256,
|
||||
PreflightCounts = new Dictionary<string, int>(),
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
Progress = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- MarkSessionFailed / MarkSessionSucceeded ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MarkSessionFailed_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var sessionId = "dee86277-0007-4b7c-a1d2-52f3aaaa0013";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Session {sessionId} marked as failed"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Session {sessionId} marked as failed"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSessionSucceeded_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var sessionId = "eff97388-0008-4b7c-a1d2-52f3aaaa0014";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Session {sessionId} marked as succeeded"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Session {sessionId} marked as succeeded"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- QueueSessionAction --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void QueueSessionActionResponse_matches_the_queue_acknowledgement_shape()
|
||||
{
|
||||
var actionType = "request_diagnostics";
|
||||
var queuedAt = new DateTime(2026, 8, 30, 13, 37, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Action '{actionType}' queued for delivery",
|
||||
queuedAt
|
||||
},
|
||||
new QueueSessionActionResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Action '{actionType}' queued for delivery",
|
||||
QueuedAt = queuedAt
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Xunit;
|
||||
@@ -56,6 +57,9 @@ public sealed class SharedManifestParityTests
|
||||
["$comment"] = "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. " +
|
||||
"Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, " +
|
||||
"then node scripts/generate-shared-manifest-types.js.",
|
||||
// v2: adds the "types" section (full wire-type graph) and drops v1's
|
||||
// "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types").
|
||||
["schemaVersion"] = 2,
|
||||
["adminConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
|
||||
@@ -64,10 +68,6 @@ public sealed class SharedManifestParityTests
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(TenantConfiguration)),
|
||||
},
|
||||
["sessionSummary"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldsWithOptionality(typeof(SessionSummary)),
|
||||
},
|
||||
// Enum member order is load-bearing (append-only ordinals) — declaration order kept.
|
||||
["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
|
||||
["enrollmentPhases"] = EnumMap<EnrollmentPhase>(),
|
||||
@@ -75,6 +75,12 @@ public sealed class SharedManifestParityTests
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Ops vocabularies. Declaration order kept for severities — it IS the ladder
|
||||
// (Info < Warning < Error < Critical), and the MCP renders it in that order.
|
||||
["opsEventCategories"] = ConstStrings(typeof(OpsEventCategory)),
|
||||
["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)),
|
||||
// Declaration order (grouped by category) — the MCP renders this catalog verbatim.
|
||||
["opsEventTypes"] = OpsEventTypes.All,
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
|
||||
@@ -84,6 +90,9 @@ public sealed class SharedManifestParityTests
|
||||
// Declaration order kept: the web derives a union type from this list, order is cosmetic
|
||||
// but a stable order keeps the generated file diff-minimal.
|
||||
["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)),
|
||||
// Every IApiResponse implementer + [WireContract] type, transitively closed —
|
||||
// the source of utils/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
@@ -99,33 +108,10 @@ public sealed class SharedManifestParityTests
|
||||
private static string[] WireFieldNames(Type type)
|
||||
=> WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Wire names plus whether the field can be ABSENT from the JSON: nullable fields are
|
||||
/// omitted under WhenWritingNull, so nullable ⇒ the TS mirror must type it optional.
|
||||
/// </summary>
|
||||
private static object[] WireFieldsWithOptionality(Type type)
|
||||
{
|
||||
var nullability = new NullabilityInfoContext();
|
||||
return WireProperties(type)
|
||||
.Select(p => (object)new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
["optional"] = IsNullable(p, nullability),
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> WireProperties(Type type)
|
||||
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
|
||||
|
||||
private static bool IsNullable(PropertyInfo p, NullabilityInfoContext ctx)
|
||||
{
|
||||
if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true;
|
||||
if (p.PropertyType.IsValueType) return false;
|
||||
return ctx.Create(p).ReadState != NullabilityState.NotNull;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> EnumMap<TEnum>() where TEnum : struct, Enum
|
||||
=> Enum.GetValues<TEnum>()
|
||||
.ToDictionary(v => v.ToString(), v => Convert.ToInt32(v));
|
||||
|
||||
@@ -65,7 +65,15 @@ public class SlaMetricsServiceTests
|
||||
int? durationSeconds = null,
|
||||
DateTime? startedAt = null)
|
||||
{
|
||||
var started = startedAt ?? DateTime.UtcNow.AddHours(-1);
|
||||
// The CurrentWeek asserts require the session to fall into the CURRENT ISO week
|
||||
// (SlaMetricsService groups by GetIsoWeekKey, weeks start Monday 00:00 UTC).
|
||||
// A plain "now - 1h" crosses into LAST week during the first hour of every
|
||||
// Monday (UTC) and empties CurrentWeek — exactly the CI failure on
|
||||
// Mon 2026-08-31 00:20Z. Clamp the anchor to the week's Monday instead.
|
||||
var now = DateTime.UtcNow;
|
||||
var isoWeekMonday = now.Date.AddDays(-(((int)now.DayOfWeek + 6) % 7));
|
||||
var anHourAgo = now.AddHours(-1);
|
||||
var started = startedAt ?? (anHourAgo >= isoWeekMonday ? anHourAgo : isoWeekMonday);
|
||||
return new SessionSummary
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
|
||||
@@ -168,11 +168,8 @@ public class SlaTenantStatusLifecycleTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook,
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration());
|
||||
@@ -183,7 +180,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
_maintenanceRepo.Object,
|
||||
_sessionRepo.Object,
|
||||
_metricsRepo.Object,
|
||||
webhook,
|
||||
channelDispatcher,
|
||||
NotificationService.Object,
|
||||
StatusRepo,
|
||||
_adminConfig.Object,
|
||||
@@ -419,9 +416,8 @@ public class SlaTenantStatusLifecycleTests
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(recentPage, null));
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(adminCfg.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook, NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object);
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
var opsSvc = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
@@ -432,7 +428,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
var svc = new SlaBreachEvaluationService(
|
||||
tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object,
|
||||
metricsRepo.Object,
|
||||
webhook, notif.Object, statusRepo, adminCfg.Object, opsSvc,
|
||||
channelDispatcher, notif.Object, statusRepo, adminCfg.Object, opsSvc,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
NullLogger<SlaBreachEvaluationService>.Instance);
|
||||
|
||||
@@ -850,14 +846,13 @@ public class SlaTenantStatusLifecycleTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook, NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Service = new SlaBreachEvaluationService(
|
||||
tenantConfigService.Object, _configRepo.Object, _maintenanceRepo.Object, _sessionRepo.Object,
|
||||
_metricsRepo.Object, webhook, notifService.Object, StatusRepo, adminConfig.Object, opsService,
|
||||
_metricsRepo.Object, channelDispatcher, notifService.Object, StatusRepo, adminConfig.Object, opsService,
|
||||
new TelemetryClient(new TelemetryConfiguration()), NullLogger<SlaBreachEvaluationService>.Instance);
|
||||
|
||||
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
|
||||
|
||||
+1
-7
@@ -283,13 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
return (
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
|
||||
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
|
||||
/// tenant admin from sending through the platform-owned bot.
|
||||
/// </summary>
|
||||
public class TelegramChannelProviderTests
|
||||
{
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
|
||||
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
||||
|
||||
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
|
||||
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert);
|
||||
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
|
||||
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
||||
}, alert);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_SkipsChannelsWithoutDestination()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, null, "tg"),
|
||||
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
||||
}, new NotificationAlert { Title = "t", Summary = "s" });
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendWithResult_RoutesTelegramToBot()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(
|
||||
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
||||
new NotificationAlert { Title = "t", Summary = "s" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Chat-ID validation ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("-1003785642894")]
|
||||
[InlineData("123456789")]
|
||||
[InlineData("@salesdesk")]
|
||||
[InlineData("@Sales_Desk_01")]
|
||||
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://api.telegram.org/bot123/sendMessage")]
|
||||
[InlineData("-100abc")]
|
||||
[InlineData("@abc")] // too short
|
||||
[InlineData("@1sales")] // must start with a letter
|
||||
[InlineData("@sales-desk")] // hyphen not allowed
|
||||
[InlineData("123456789012345678901")] // 21 digits
|
||||
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
|
||||
{
|
||||
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
|
||||
// provider-specific destination branch is wired, not just the format helper.
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
|
||||
|
||||
// ── Global-Admin gate ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_GlobalAdminMayAddChannel()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotAddChannel()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
|
||||
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
|
||||
{
|
||||
// The gate must not turn a GA-created Telegram channel into a permanent save block
|
||||
// for the tenant admin's unrelated configuration changes.
|
||||
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_IgnoresNonTelegramChannels()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
|
||||
|
||||
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
}
|
||||
@@ -224,10 +224,11 @@ public class TenantAdminManagementFunctionTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
// Mirror the production camelCase policy so camelCase test bodies bind to the
|
||||
// PascalCase request models exactly like real web requests do.
|
||||
// Production wire settings (camelCase policy binds camelCase test bodies to the
|
||||
// PascalCase request models exactly like real web requests do; absent-when-null and
|
||||
// string enums match what the deployed worker serializes).
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var requestContext = new RequestContext
|
||||
|
||||
@@ -51,13 +51,7 @@ public class TenantApprovalServiceTests
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
|
||||
@@ -48,13 +48,7 @@ public class TenantAutoApproveHandlerTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_approvalMock = new Mock<TenantApprovalService>(
|
||||
|
||||
@@ -99,13 +99,7 @@ public class TenantConfigPatchServiceTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(OpsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Sut = new TenantConfigPatchService(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The TenantTrialStarted ops event — the conversion signal a sales/support channel binds to.
|
||||
/// Covers the recorded shape (category, severity, payload) and the GA-path emission predicate.
|
||||
/// <para>
|
||||
/// The HTTP entry points themselves are not exercised: per this suite's convention the pure
|
||||
/// cores are tested instead (see PlanManagementTransitionTests), so the GA path is pinned via
|
||||
/// the changes dictionary that gates the call.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class TenantTrialStartedEventTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Caller = "ga@operator.example";
|
||||
|
||||
private static (OpsEventService Service, List<OpsEventEntry> Events) NewService()
|
||||
{
|
||||
var events = new List<OpsEventEntry>();
|
||||
var repo = new Mock<IOpsEventRepository>();
|
||||
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(events.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
|
||||
|
||||
return (new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object)), events);
|
||||
}
|
||||
|
||||
// ── Recorded shape ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordTenantTrialStarted_WritesTenantInfoEventWithContactPayload()
|
||||
{
|
||||
var (service, events) = NewService();
|
||||
|
||||
await service.RecordTenantTrialStartedAsync(
|
||||
"t1", "contoso.example", "admin@contoso.example", Now, Now.AddDays(30), Caller, selfService: true);
|
||||
|
||||
var entry = Assert.Single(events);
|
||||
Assert.Equal(OpsEventCategory.Tenant, entry.Category);
|
||||
Assert.Equal("TenantTrialStarted", entry.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
|
||||
Assert.Equal("t1", entry.TenantId);
|
||||
Assert.Equal(Caller, entry.UserId);
|
||||
Assert.Contains("contoso.example", entry.Message);
|
||||
|
||||
using var details = JsonDocument.Parse(entry.Details!);
|
||||
var root = details.RootElement;
|
||||
Assert.Equal("contoso.example", root.GetProperty("domainName").GetString());
|
||||
Assert.Equal("admin@contoso.example", root.GetProperty("contactEmail").GetString());
|
||||
Assert.Equal(Caller, root.GetProperty("grantedBy").GetString());
|
||||
Assert.True(root.GetProperty("selfService").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordTenantTrialStarted_DistinguishesOperatorGrantFromSelfService()
|
||||
{
|
||||
var (service, events) = NewService();
|
||||
|
||||
await service.RecordTenantTrialStartedAsync(
|
||||
"t1", "contoso.example", null, Now, Now.AddDays(90), Caller, selfService: false);
|
||||
|
||||
Assert.Contains("granted by an operator", events.Single().Message);
|
||||
using var details = JsonDocument.Parse(events.Single().Details!);
|
||||
Assert.False(details.RootElement.GetProperty("selfService").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordTenantTrialStarted_FallsBackToTenantIdWhenDomainUnknown()
|
||||
{
|
||||
var (service, events) = NewService();
|
||||
|
||||
await service.RecordTenantTrialStartedAsync("t1", null, null, Now, Now.AddDays(30), Caller, selfService: true);
|
||||
|
||||
Assert.Contains("t1", events.Single().Message);
|
||||
}
|
||||
|
||||
// ── GA-path emission predicate ────────────────────────────────────────
|
||||
// SetPlanTier fires when: changes["TrialExpiresUtc"] was recorded AND a trial end remains.
|
||||
|
||||
private static (Dictionary<string, string> Changes, TenantConfiguration Config) Apply(
|
||||
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
|
||||
{
|
||||
var changes = new Dictionary<string, string>();
|
||||
PlanManagementFunction.ApplyPlanChanges(config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
|
||||
return (changes, config);
|
||||
}
|
||||
|
||||
private static bool WouldFire((Dictionary<string, string> Changes, TenantConfiguration Config) result)
|
||||
=> result.Changes.ContainsKey("TrialExpiresUtc") && result.Config.TrialExpiresUtc.HasValue;
|
||||
|
||||
[Fact]
|
||||
public void GaGrantingATrial_Fires()
|
||||
=> Assert.True(WouldFire(Apply(
|
||||
new TenantConfiguration { TenantId = "t1", PlanTier = "community" },
|
||||
trialProvided: true, trialExpiresUtc: Now.AddDays(30))));
|
||||
|
||||
[Fact]
|
||||
public void GaExtendingATrial_Fires()
|
||||
=> Assert.True(WouldFire(Apply(
|
||||
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) },
|
||||
trialProvided: true, trialExpiresUtc: Now.AddDays(60))));
|
||||
|
||||
[Fact]
|
||||
public void GaEndingATrial_DoesNotFire()
|
||||
{
|
||||
// Explicit null ends the trial — that is a downgrade, already covered by
|
||||
// TenantPlanDowngraded. Firing "started" there would be plainly wrong.
|
||||
Assert.False(WouldFire(Apply(
|
||||
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = Now.AddDays(5) },
|
||||
trialProvided: true, trialExpiresUtc: null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanTierOnlyChange_DoesNotFire()
|
||||
=> Assert.False(WouldFire(Apply(
|
||||
new TenantConfiguration { TenantId = "t1", PlanTier = "community" }, planTier: "pro")));
|
||||
|
||||
[Fact]
|
||||
public void ResubmittingTheSameTrialDate_DoesNotFire()
|
||||
{
|
||||
// ApplyPlanChanges only records a change when the value actually moved, so an idempotent
|
||||
// save must not re-announce the conversion.
|
||||
var expiry = Now.AddDays(30);
|
||||
|
||||
Assert.False(WouldFire(Apply(
|
||||
new TenantConfiguration { TenantId = "t1", PlanTier = "community", TrialExpiresUtc = expiry },
|
||||
trialProvided: true, trialExpiresUtc: expiry)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> but do not
|
||||
/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare
|
||||
/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport
|
||||
/// resolves its bot URL from a mocked repository that returns nothing.
|
||||
/// <para>
|
||||
/// Tests that DO assert on dispatch should mock the transports directly — see
|
||||
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TestNotifications
|
||||
{
|
||||
internal static NotificationChannelDispatcher InertDispatcher()
|
||||
=> new(
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance));
|
||||
|
||||
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
|
||||
=> new(adminConfigService, InertDispatcher(), NullLogger<OpsAlertDispatchService>.Instance);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes test payloads with the PRODUCTION wire options (<see cref="ApiJsonOptions"/> —
|
||||
/// camelCase, WhenWritingNull), the policy every test harness with its own serializer must
|
||||
/// follow. Shape tests serialized DTOs with default options before the 2026-08-31 completion
|
||||
/// pass; that only worked while the payloads were anonymous objects with lowercase members.
|
||||
/// Generic so the CONCRETE type reaches System.Text.Json (a declared object parameter would
|
||||
/// too, but the generic also keeps compile-time typing at the call site).
|
||||
/// </summary>
|
||||
internal static class TestWire
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create();
|
||||
|
||||
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
|
||||
|
||||
public static JsonElement SerializeToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
|
||||
}
|
||||
+1
-1
@@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
// returned 31 day keys under a "windowDays: 30" label.
|
||||
var today = new System.DateTime(2026, 7, 27);
|
||||
Assert.Equal(today.AddDays(-29),
|
||||
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
|
||||
Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,7 @@ public class TrialExpirySweepFunctionTests
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
|
||||
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var sut = new TrialExpirySweepFunction(
|
||||
@@ -248,11 +244,7 @@ public class TrialExpirySweepFunctionTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
var sut = new TrialExpirySweepFunction(
|
||||
configRepo.Object,
|
||||
|
||||
+879
@@ -0,0 +1,879 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using AutopilotMonitor.Shared.Models.Metrics;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proofs for the 2026-08-31 typed-contract COMPLETION pass: the endpoints the
|
||||
/// original migration missed because their anonymous literal was smuggled past the ratchet
|
||||
/// (variable indirection, object-returning builders, Serialize+WriteString, local WriteJson
|
||||
/// wrappers). Same contract as <see cref="ApiResponseWireParityTests"/>: OLD anonymous
|
||||
/// literal (copied from the pre-conversion diff) vs NEW DTO, ordinal JSON equality, plus a
|
||||
/// null-slot case wherever a key must vanish. Sites converted by MOVING an already-named
|
||||
/// class (PlatformAgentMetricsResponse family, AgentEfficiency family, VerdictCalibration
|
||||
/// path rows, DeviceJourneyWindowTotals, OffboardResponse) are wire-identical by
|
||||
/// construction (System.Text.Json ignores namespaces) and carry no fact.
|
||||
/// </summary>
|
||||
public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
private static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
|
||||
// ── rule-stats (tenant + global) ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RuleStatsResponse_matches_the_tenant_shape_without_uniqueRules()
|
||||
{
|
||||
var regressions = new List<RuleRegressionAlert>();
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
rules = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ruleId = "ANALYZE-NET-001",
|
||||
ruleType = "analyze",
|
||||
ruleTitle = "Network issue",
|
||||
category = "network",
|
||||
severity = "warning",
|
||||
fireCount = 3,
|
||||
evaluationCount = 10,
|
||||
sessionsEvaluated = 9,
|
||||
hitRate = 30.0,
|
||||
avgConfidenceScore = 71.5,
|
||||
trend = new[] { new { date = "2026-08-30", fireCount = 3, evaluationCount = 10 } },
|
||||
},
|
||||
},
|
||||
regressions,
|
||||
summary = new
|
||||
{
|
||||
totalEvaluations = 10,
|
||||
totalFires = 3,
|
||||
overallHitRate = 30.0,
|
||||
topRuleByFireCount = (string?)"ANALYZE-NET-001",
|
||||
period = new { start = "2026-08-01", end = "2026-08-31" },
|
||||
},
|
||||
},
|
||||
new RuleStatsResponse
|
||||
{
|
||||
Rules = new[]
|
||||
{
|
||||
new RuleStatsRuleAggregate
|
||||
{
|
||||
RuleId = "ANALYZE-NET-001",
|
||||
RuleType = "analyze",
|
||||
RuleTitle = "Network issue",
|
||||
Category = "network",
|
||||
Severity = "warning",
|
||||
FireCount = 3,
|
||||
EvaluationCount = 10,
|
||||
SessionsEvaluated = 9,
|
||||
HitRate = 30.0,
|
||||
AvgConfidenceScore = 71.5,
|
||||
Trend = new[] { new RuleTrendPoint { Date = "2026-08-30", FireCount = 3, EvaluationCount = 10 } },
|
||||
},
|
||||
},
|
||||
Regressions = regressions,
|
||||
// Tenant route: UniqueRules stays null → the key is absent, matching the
|
||||
// historical tenant shape which never carried it.
|
||||
Summary = new RuleStatsSummary
|
||||
{
|
||||
TotalEvaluations = 10,
|
||||
TotalFires = 3,
|
||||
OverallHitRate = 30.0,
|
||||
TopRuleByFireCount = "ANALYZE-NET-001",
|
||||
UniqueRules = null,
|
||||
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleStatsResponse_matches_the_global_shape_with_uniqueRules_and_null_topRule()
|
||||
{
|
||||
var regressions = new List<RuleRegressionAlert>();
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
rules = Array.Empty<object>(),
|
||||
regressions,
|
||||
summary = new
|
||||
{
|
||||
totalEvaluations = 0,
|
||||
totalFires = 0,
|
||||
overallHitRate = 0.0,
|
||||
topRuleByFireCount = (string?)null, // empty window → key vanishes
|
||||
uniqueRules = 0,
|
||||
period = new { start = "2026-08-01", end = "2026-08-31" },
|
||||
},
|
||||
},
|
||||
new RuleStatsResponse
|
||||
{
|
||||
Rules = Array.Empty<RuleStatsRuleAggregate>(),
|
||||
Regressions = regressions,
|
||||
Summary = new RuleStatsSummary
|
||||
{
|
||||
TotalEvaluations = 0,
|
||||
TotalFires = 0,
|
||||
OverallHitRate = 0.0,
|
||||
TopRuleByFireCount = null,
|
||||
UniqueRules = 0,
|
||||
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── verdict-calibration ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void VerdictCalibrationResponse_matches_the_envelope_incl_computedAt_omission()
|
||||
{
|
||||
// Path rows and alerts were ALREADY named classes on the old wire (moved verbatim) —
|
||||
// the envelope keys are what the conversion changed, so the fact pins those with the
|
||||
// row lists empty; the no-data window additionally proves computedAt vanishes.
|
||||
var paths = new List<VerdictCalibrationPathRow>();
|
||||
var alerts = new List<VerdictCalibrationAlert>();
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId = "global",
|
||||
windowDays = 30,
|
||||
windowStart = "2026-08-02",
|
||||
windowEnd = "2026-08-31",
|
||||
computedAt = (DateTime?)null,
|
||||
versions = new[] { 1, 2 },
|
||||
totals = new { sessions = 0, terminal = 0, derived = 0, days = 0 },
|
||||
trend = new { windowDays = 7, baselineDays = 28, windowSessions = 0, baselineSessions = 0 },
|
||||
paths,
|
||||
alerts,
|
||||
},
|
||||
new VerdictCalibrationResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = "global",
|
||||
WindowDays = 30,
|
||||
WindowStart = "2026-08-02",
|
||||
WindowEnd = "2026-08-31",
|
||||
ComputedAt = null,
|
||||
Versions = new[] { 1, 2 },
|
||||
Totals = new VerdictCalibrationTotals { Sessions = 0, Terminal = 0, Derived = 0, Days = 0 },
|
||||
Trend = new VerdictCalibrationTrendMeta { WindowDays = 7, BaselineDays = 28, WindowSessions = 0, BaselineSessions = 0 },
|
||||
Paths = paths,
|
||||
Alerts = alerts,
|
||||
});
|
||||
}
|
||||
|
||||
// ── metrics/app ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AppMetricsResponse_matches_the_anonymous_shape()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
totalApps = 1,
|
||||
totalInstalls = 4,
|
||||
totalSkipped = 1,
|
||||
totalUnmeasured = 1,
|
||||
totalCollisionExcluded = 0,
|
||||
slowestApps = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
totalInstalls = 4,
|
||||
succeeded = 2,
|
||||
skipped = 1,
|
||||
unmeasured = 1,
|
||||
failed = 1,
|
||||
failureRate = 33.3,
|
||||
avgDurationSeconds = 120.0,
|
||||
maxDurationSeconds = 240,
|
||||
measuredInstalls = 1,
|
||||
avgDownloadBytes = 1048576L,
|
||||
doTotalBytesDownloaded = 2097152L,
|
||||
doBytesFromPeers = 1048576L,
|
||||
doBytesFromCacheServer = 0L,
|
||||
doBytesFromHttp = 1048576L,
|
||||
peerOffloadPercent = 50.0,
|
||||
topFailureCodes = new[] { new { code = "0x80070005", count = 1 } },
|
||||
},
|
||||
},
|
||||
topFailingApps = Array.Empty<object>(),
|
||||
deliveryOptimization = new
|
||||
{
|
||||
totalBytesDownloaded = 2097152L,
|
||||
fromPeers = 1048576L,
|
||||
fromCacheServer = 0L,
|
||||
fromHttp = 1048576L,
|
||||
peerOffloadPercent = 50.0,
|
||||
},
|
||||
},
|
||||
new AppMetricsResponse
|
||||
{
|
||||
Success = true,
|
||||
TotalApps = 1,
|
||||
TotalInstalls = 4,
|
||||
TotalSkipped = 1,
|
||||
TotalUnmeasured = 1,
|
||||
TotalCollisionExcluded = 0,
|
||||
SlowestApps = new[]
|
||||
{
|
||||
new AppMetricsAppGroup
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
TotalInstalls = 4,
|
||||
Succeeded = 2,
|
||||
Skipped = 1,
|
||||
Unmeasured = 1,
|
||||
Failed = 1,
|
||||
FailureRate = 33.3,
|
||||
AvgDurationSeconds = 120.0,
|
||||
MaxDurationSeconds = 240,
|
||||
MeasuredInstalls = 1,
|
||||
AvgDownloadBytes = 1048576L,
|
||||
DoTotalBytesDownloaded = 2097152L,
|
||||
DoBytesFromPeers = 1048576L,
|
||||
DoBytesFromCacheServer = 0L,
|
||||
DoBytesFromHttp = 1048576L,
|
||||
PeerOffloadPercent = 50.0,
|
||||
TopFailureCodes = new[] { new AppFailureCodeCount { Code = "0x80070005", Count = 1 } },
|
||||
},
|
||||
},
|
||||
TopFailingApps = Array.Empty<AppMetricsAppGroup>(),
|
||||
DeliveryOptimization = new AppMetricsDeliveryOptimization
|
||||
{
|
||||
TotalBytesDownloaded = 2097152L,
|
||||
FromPeers = 1048576L,
|
||||
FromCacheServer = 0L,
|
||||
FromHttp = 1048576L,
|
||||
PeerOffloadPercent = 50.0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── metrics/ime-versions (non-global projection) ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ImeVersionHistoryLeanEntry_matches_the_anonymous_projection()
|
||||
{
|
||||
var seen = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var expected = JsonSerializer.Serialize(
|
||||
new { Version = "1.104.102.0", FirstSeenAt = seen, LastSeenAt = seen.AddDays(3), SessionCount = 12 },
|
||||
ApiJsonOptions.Create());
|
||||
var actual = JsonSerializer.Serialize(
|
||||
new ImeVersionHistoryLeanEntry
|
||||
{
|
||||
Version = "1.104.102.0",
|
||||
FirstSeenAt = seen,
|
||||
LastSeenAt = seen.AddDays(3),
|
||||
SessionCount = 12,
|
||||
},
|
||||
ApiJsonOptions.Create());
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// ── metrics/time-attribution (fleet) ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TimeAttributionMetricsResponse_matches_the_envelope()
|
||||
{
|
||||
// Row lists are TimeAttributionDailyAggregate on BOTH sides (identical class) —
|
||||
// the envelope keys are the conversion.
|
||||
var rows = new List<TimeAttributionDailyAggregate>();
|
||||
AssertWireIdentical(
|
||||
new { success = true, windowDays = 30, classes = rows, daily = rows },
|
||||
new TimeAttributionMetricsResponse { Success = true, WindowDays = 30, Classes = rows, Daily = rows });
|
||||
}
|
||||
|
||||
// ── metrics/device-journeys ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DeviceJourneyMetricsResponse_matches_the_envelope_incl_repeatDevices_omission()
|
||||
{
|
||||
var totals = new DeviceJourneyWindowTotals { CompletedJourneys = 0, FirstTimeRight = 0, FtrRatePct = null, ExcludedSessions = 0 };
|
||||
var daily = new List<DeviceJourneyDailyAggregate>();
|
||||
// Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap).
|
||||
AssertWireIdentical(
|
||||
new { success = true, windowDays = 30, totals, daily, repeatDevices = (List<object>?)null },
|
||||
new DeviceJourneyMetricsResponse { Success = true, WindowDays = 30, Totals = totals, Daily = daily, RepeatDevices = null });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceJourneyRepeatDevice_matches_the_anonymous_row()
|
||||
{
|
||||
var started = new DateTime(2026, 8, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
windowDays = 30,
|
||||
totals = new DeviceJourneyWindowTotals(),
|
||||
daily = new List<DeviceJourneyDailyAggregate>(),
|
||||
repeatDevices = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serialNumber = "SER123",
|
||||
manufacturer = "Contoso",
|
||||
model = "Book 3",
|
||||
attempts = 3,
|
||||
journeyCount = 2,
|
||||
lastStatus = "Failed",
|
||||
lastSessionId = "22222222-2222-2222-2222-222222222222",
|
||||
lastStartedAt = started,
|
||||
lastFailureReason = "ESP timeout",
|
||||
},
|
||||
},
|
||||
},
|
||||
new DeviceJourneyMetricsResponse
|
||||
{
|
||||
Success = true,
|
||||
WindowDays = 30,
|
||||
Totals = new DeviceJourneyWindowTotals(),
|
||||
Daily = new List<DeviceJourneyDailyAggregate>(),
|
||||
RepeatDevices = new[]
|
||||
{
|
||||
new DeviceJourneyRepeatDevice
|
||||
{
|
||||
SerialNumber = "SER123",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Book 3",
|
||||
Attempts = 3,
|
||||
JourneyCount = 2,
|
||||
LastStatus = "Failed",
|
||||
LastSessionId = "22222222-2222-2222-2222-222222222222",
|
||||
LastStartedAt = started,
|
||||
LastFailureReason = "ESP timeout",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── config/{tenantId}/feature-flags ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TenantFeatureFlagsResponse_matches_the_anonymous_shape_incl_null_omissions()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
bootstrapTokenEnabled = true,
|
||||
diagnosticsUploadConfigured = false,
|
||||
validateAutopilotDevice = true,
|
||||
appHomingFunnelActive = false,
|
||||
showScriptOutput = true,
|
||||
enableSoftwareInventoryAnalyzer = false,
|
||||
enableIntegrityBypassAnalyzer = true,
|
||||
unrestrictedMode = false,
|
||||
edition = "community",
|
||||
isTrial = false,
|
||||
trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes
|
||||
trialAvailable = true,
|
||||
contactEmailSet = false,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = 90,
|
||||
userRateLimitPerMinute = (int?)null, // platform default → key vanishes
|
||||
delegatedAdminAllowed = false,
|
||||
mcpUsagePlan = "community",
|
||||
},
|
||||
},
|
||||
new TenantFeatureFlagsResponse
|
||||
{
|
||||
BootstrapTokenEnabled = true,
|
||||
DiagnosticsUploadConfigured = false,
|
||||
ValidateAutopilotDevice = true,
|
||||
AppHomingFunnelActive = false,
|
||||
ShowScriptOutput = true,
|
||||
EnableSoftwareInventoryAnalyzer = false,
|
||||
EnableIntegrityBypassAnalyzer = true,
|
||||
UnrestrictedMode = false,
|
||||
Edition = "community",
|
||||
IsTrial = false,
|
||||
TrialExpiresUtc = null,
|
||||
TrialAvailable = true,
|
||||
ContactEmailSet = false,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = 90,
|
||||
UserRateLimitPerMinute = null,
|
||||
DelegatedAdminAllowed = false,
|
||||
McpUsagePlan = "community",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── diagnostics/paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsPathsResponse_matches_the_anonymous_shape()
|
||||
{
|
||||
var globalPaths = new List<DiagnosticsLogPath>();
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
builtIn = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "AgentLogs",
|
||||
zipFolder = "AgentLogs",
|
||||
sourceFolder = "%ProgramData%/AutopilotMonitor",
|
||||
patterns = new[] { "*.log" },
|
||||
includeSubfolders = false,
|
||||
description = "Agent log files",
|
||||
condition = "Always",
|
||||
},
|
||||
},
|
||||
globalPaths,
|
||||
},
|
||||
new DiagnosticsPathsResponse
|
||||
{
|
||||
BuiltIn = new[]
|
||||
{
|
||||
new DiagnosticsBuiltInSectionWire
|
||||
{
|
||||
Id = "AgentLogs",
|
||||
ZipFolder = "AgentLogs",
|
||||
SourceFolder = "%ProgramData%/AutopilotMonitor",
|
||||
Patterns = new[] { "*.log" },
|
||||
IncludeSubfolders = false,
|
||||
Description = "Agent log files",
|
||||
Condition = "Always",
|
||||
},
|
||||
},
|
||||
GlobalPaths = globalPaths,
|
||||
});
|
||||
}
|
||||
|
||||
// ── sessions restore / delete-queued ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SessionRestoreResponse_matches_the_anonymous_shape_incl_null_omissions()
|
||||
{
|
||||
var rows = new Dictionary<string, int>(StringComparer.Ordinal) { ["Sessions"] = 1 };
|
||||
var empty = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
outcome = "Restored",
|
||||
mode = (string?)"full",
|
||||
message = (string?)null, // clean success → key vanishes
|
||||
currentState = (string?)null,
|
||||
pendingManifestId = (string?)null,
|
||||
rowsRestoredByTable = rows,
|
||||
rowsSkippedByTable = empty,
|
||||
wouldRestoreByTable = empty,
|
||||
inventoryReIncrements = 2,
|
||||
durationMs = 1234L,
|
||||
},
|
||||
new SessionRestoreResponse
|
||||
{
|
||||
Success = true,
|
||||
Outcome = "Restored",
|
||||
Mode = "full",
|
||||
Message = null,
|
||||
CurrentState = null,
|
||||
PendingManifestId = null,
|
||||
RowsRestoredByTable = rows,
|
||||
RowsSkippedByTable = empty,
|
||||
WouldRestoreByTable = empty,
|
||||
InventoryReIncrements = 2,
|
||||
DurationMs = 1234L,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionDeletionQueuedResponse_matches_the_anonymous_Enqueued_arm()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
status = "queued",
|
||||
manifestId = (string?)"01J0123456789ABCDEFGHIJKLM",
|
||||
message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||
},
|
||||
new SessionDeletionQueuedResponse
|
||||
{
|
||||
Success = true,
|
||||
Status = "queued",
|
||||
ManifestId = "01J0123456789ABCDEFGHIJKLM",
|
||||
Message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── feedback ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Feedback_responses_match_the_anonymous_shapes()
|
||||
{
|
||||
AssertWireIdentical(new { eligible = true }, new FeedbackEligibilityResponse { Eligible = true });
|
||||
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
feedback = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "InApp",
|
||||
upn = "alice@contoso.com",
|
||||
tenantId = "11111111-1111-1111-1111-111111111111",
|
||||
displayName = "Alice",
|
||||
rating = (int?)5,
|
||||
comment = (string?)"Great",
|
||||
dismissed = false,
|
||||
submitted = true,
|
||||
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
|
||||
historyRowKey = (string?)null, // in-app entry → key vanishes
|
||||
domainName = (string?)null,
|
||||
},
|
||||
},
|
||||
},
|
||||
new FeedbackListResponse
|
||||
{
|
||||
Feedback = new[]
|
||||
{
|
||||
new FeedbackEntryWire
|
||||
{
|
||||
Type = "InApp",
|
||||
Upn = "alice@contoso.com",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
DisplayName = "Alice",
|
||||
Rating = 5,
|
||||
Comment = "Great",
|
||||
Dismissed = false,
|
||||
Submitted = true,
|
||||
InteractedAt = "2026-08-30T10:00:00.0000000Z",
|
||||
HistoryRowKey = null,
|
||||
DomainName = null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── global/backups (custom serializer options) ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ListBackupsResponse_matches_the_anonymous_shape_under_backup_options()
|
||||
{
|
||||
var ids = new List<string> { "20260831T010203Z_abcd1234" };
|
||||
var expected = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
|
||||
var actual = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// ── apps list / analytics / sessions ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AppsListResponse_matches_the_legacy_and_paged_shapes()
|
||||
{
|
||||
var item = new AppsListItem
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
AppType = "Win32",
|
||||
TotalInstalls = 5,
|
||||
Succeeded = 3,
|
||||
Skipped = 1,
|
||||
Unmeasured = 0,
|
||||
Failed = 1,
|
||||
FailureRate = 25.0,
|
||||
AvgDurationSeconds = 60.0,
|
||||
MaxDurationSeconds = 120,
|
||||
AvgDownloadBytes = 1024L,
|
||||
Trend = "stable",
|
||||
TrendDelta = null, // under 5 finished installs in a half → key vanishes
|
||||
LastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
var anonymousItem = new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
appType = "Win32",
|
||||
totalInstalls = 5,
|
||||
succeeded = 3,
|
||||
skipped = 1,
|
||||
unmeasured = 0,
|
||||
failed = 1,
|
||||
failureRate = 25.0,
|
||||
avgDurationSeconds = 60.0,
|
||||
maxDurationSeconds = 120,
|
||||
avgDownloadBytes = 1024L,
|
||||
trend = "stable",
|
||||
trendDelta = (double?)null,
|
||||
lastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// Legacy mode: no paging keys at all.
|
||||
AssertWireIdentical(
|
||||
new { success = true, totalApps = 1, totalInstalls = 5, collisionExcluded = 0, windowDays = 30, apps = new[] { anonymousItem } },
|
||||
new AppsListResponse
|
||||
{
|
||||
Success = true,
|
||||
TotalApps = 1,
|
||||
TotalInstalls = 5,
|
||||
CollisionExcluded = 0,
|
||||
WindowDays = 30,
|
||||
Apps = new[] { item },
|
||||
});
|
||||
|
||||
// Paged mode incl. last-page nextLink omission.
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
totalApps = 1,
|
||||
totalInstalls = 5,
|
||||
collisionExcluded = 0,
|
||||
windowDays = 30,
|
||||
count = 1,
|
||||
offset = 0,
|
||||
pageSize = 100,
|
||||
apps = new[] { anonymousItem },
|
||||
nextLink = (string?)null,
|
||||
},
|
||||
new AppsListResponse
|
||||
{
|
||||
Success = true,
|
||||
TotalApps = 1,
|
||||
TotalInstalls = 5,
|
||||
CollisionExcluded = 0,
|
||||
WindowDays = 30,
|
||||
Count = 1,
|
||||
Offset = 0,
|
||||
PageSize = 100,
|
||||
Apps = new[] { item },
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppAnalyticsResponse_matches_the_empty_window_shape()
|
||||
{
|
||||
var regressions = new List<AppVersionRegressionAlert>();
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
appName = "Contoso App",
|
||||
appType = string.Empty,
|
||||
windowDays = 30,
|
||||
collisionExcluded = 0,
|
||||
bucket = "day",
|
||||
summary = new
|
||||
{
|
||||
totalInstalls = 0,
|
||||
succeeded = 0,
|
||||
skipped = 0,
|
||||
unmeasured = 0,
|
||||
failed = 0,
|
||||
failureRate = 0.0,
|
||||
avgDurationSeconds = 0.0,
|
||||
p95DurationSeconds = 0,
|
||||
avgDownloadBytes = 0L,
|
||||
trend = "stable",
|
||||
trendDelta = (double?)null,
|
||||
flakinessScore = 0.0,
|
||||
},
|
||||
timeSeries = Array.Empty<object>(),
|
||||
versionBreakdown = Array.Empty<object>(),
|
||||
installerPhaseBreakdown = Array.Empty<object>(),
|
||||
topFailureCodes = Array.Empty<object>(),
|
||||
detectionLiesCount = 0,
|
||||
deviceModelBreakdown = Array.Empty<object>(),
|
||||
versionRegressions = regressions,
|
||||
},
|
||||
new AppAnalyticsResponse
|
||||
{
|
||||
Success = true,
|
||||
AppName = "Contoso App",
|
||||
AppType = string.Empty,
|
||||
WindowDays = 30,
|
||||
CollisionExcluded = 0,
|
||||
Bucket = "day",
|
||||
Summary = new AppAnalyticsSummary
|
||||
{
|
||||
TotalInstalls = 0,
|
||||
Succeeded = 0,
|
||||
Skipped = 0,
|
||||
Unmeasured = 0,
|
||||
Failed = 0,
|
||||
FailureRate = 0.0,
|
||||
AvgDurationSeconds = 0.0,
|
||||
P95DurationSeconds = 0,
|
||||
AvgDownloadBytes = 0L,
|
||||
Trend = "stable",
|
||||
TrendDelta = null,
|
||||
FlakinessScore = 0.0,
|
||||
},
|
||||
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
|
||||
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||
DetectionLiesCount = 0,
|
||||
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||
VersionRegressions = regressions,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppAnalytics_inner_rows_match_their_anonymous_shapes()
|
||||
{
|
||||
var bucketStart = new DateTime(2026, 8, 29, 0, 0, 0, DateTimeKind.Utc);
|
||||
var wire = ApiJsonOptions.Create();
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
bucketStart,
|
||||
installs = 4,
|
||||
succeeded = 3,
|
||||
failed = 1,
|
||||
failureRate = 25.0,
|
||||
avgDurationSeconds = 90.0,
|
||||
}, wire),
|
||||
JsonSerializer.Serialize(new AppAnalyticsTimeBucket
|
||||
{
|
||||
BucketStart = bucketStart,
|
||||
Installs = 4,
|
||||
Succeeded = 3,
|
||||
Failed = 1,
|
||||
FailureRate = 25.0,
|
||||
AvgDurationSeconds = 90.0,
|
||||
}, wire));
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
appVersion = "1.2.3",
|
||||
installs = 4,
|
||||
failed = 1,
|
||||
failureRate = 25.0,
|
||||
measuredInstalls = 3,
|
||||
medianDurationSeconds = 60,
|
||||
p95DurationSeconds = 110,
|
||||
}, wire),
|
||||
JsonSerializer.Serialize(new AppVersionBreakdownItem
|
||||
{
|
||||
AppVersion = "1.2.3",
|
||||
Installs = 4,
|
||||
Failed = 1,
|
||||
FailureRate = 25.0,
|
||||
MeasuredInstalls = 3,
|
||||
MedianDurationSeconds = 60,
|
||||
P95DurationSeconds = 110,
|
||||
}, wire));
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(new { phase = "DownloadStart", failed = 2 }, wire),
|
||||
JsonSerializer.Serialize(new AppInstallerPhaseCount { Phase = "DownloadStart", Failed = 2 }, wire));
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
code = "0x80070005",
|
||||
exitCode = (int?)5,
|
||||
count = 2,
|
||||
sampleMessage = "Access denied",
|
||||
}, wire),
|
||||
JsonSerializer.Serialize(new AppAnalyticsFailureCode
|
||||
{
|
||||
Code = "0x80070005",
|
||||
ExitCode = 5,
|
||||
Count = 2,
|
||||
SampleMessage = "Access denied",
|
||||
}, wire));
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
manufacturer = "Contoso",
|
||||
model = "Book 3",
|
||||
installs = 8,
|
||||
failed = 2,
|
||||
failureRate = 25.0,
|
||||
liftVsBaseline = 1.5,
|
||||
}, wire),
|
||||
JsonSerializer.Serialize(new AppDeviceModelBreakdownItem
|
||||
{
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Book 3",
|
||||
Installs = 8,
|
||||
Failed = 2,
|
||||
FailureRate = 25.0,
|
||||
LiftVsBaseline = 1.5,
|
||||
}, wire));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSessionsResponse_matches_the_anonymous_shape()
|
||||
{
|
||||
var started = new DateTime(2026, 8, 30, 7, 30, 0, DateTimeKind.Utc);
|
||||
AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
total = 1,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = "22222222-2222-2222-2222-222222222222",
|
||||
tenantId = "11111111-1111-1111-1111-111111111111",
|
||||
deviceName = "DESKTOP-1",
|
||||
manufacturer = "Contoso",
|
||||
model = "Book 3",
|
||||
appVersion = "1.2.3",
|
||||
status = "Failed",
|
||||
installerPhase = "Install",
|
||||
failureCode = "0x80070005",
|
||||
exitCode = (int?)5,
|
||||
attemptNumber = 2,
|
||||
startedAt = started,
|
||||
durationSeconds = 90,
|
||||
installPassCount = 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
new AppSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Total = 1,
|
||||
Offset = 0,
|
||||
Limit = 50,
|
||||
Items = new[]
|
||||
{
|
||||
new AppSessionItem
|
||||
{
|
||||
SessionId = "22222222-2222-2222-2222-222222222222",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
DeviceName = "DESKTOP-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Book 3",
|
||||
AppVersion = "1.2.3",
|
||||
Status = "Failed",
|
||||
InstallerPhase = "Install",
|
||||
FailureCode = "0x80070005",
|
||||
ExitCode = 5,
|
||||
AttemptNumber = 2,
|
||||
StartedAt = started,
|
||||
DurationSeconds = 90,
|
||||
InstallPassCount = 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard against anonymous-object API SUCCESS responses (successor of the retired
|
||||
/// OkAsyncBaselineGuardTests). Anonymous success bodies ship a shape no type checks, no
|
||||
/// manifest exports, and the web mirrors by hand — the drift class the 2026-08-13 fragility
|
||||
/// audit found across 44 helper + 134 raw sites. That debt was migrated to typed DTOs in
|
||||
/// 2026-08-31 (feat/typed-api-contract), so both per-file baselines are now EMPTY and any
|
||||
/// match is a straight failure:
|
||||
/// - Regex A: <c>OkAsync/CreatedAsync/JsonAsync(new { ... })</c> through ResponseHelper
|
||||
/// (typed initializers <c>OkAsync(new SomeResponse { ... })</c> do not match),
|
||||
/// - Regex B: raw success <c>WriteAsJsonAsync(new { ... })</c>. Error bodies — first
|
||||
/// property <c>error</c>/<c>message</c>, or literal <c>success = false</c> — are
|
||||
/// tolerated and stay anonymous by design (one shape).
|
||||
/// Endpoints return typed DTOs implementing <see cref="IApiResponse"/>
|
||||
/// (AutopilotMonitor.Shared.Models, exported to TypeScript by SharedManifestParityTests),
|
||||
/// and each conversion carries an ordinal old-vs-new proof in the *WireParityTests files.
|
||||
/// </summary>
|
||||
public class TypedResponseGuardTests
|
||||
{
|
||||
// Lookbehind keeps 'JsonAsync(' from matching as the suffix of 'WriteAsJsonAsync('.
|
||||
private static readonly Regex AnonymousHelperCall =
|
||||
new(@"(?<![A-Za-z])(OkAsync|CreatedAsync|JsonAsync)\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex AnonymousWriteAsJson =
|
||||
new(@"WriteAsJsonAsync\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// EMPTY since 2026-08-31 — every anonymous helper success body is typed (the migration
|
||||
/// started from the frozen 2026-08-13 baseline of 44 sites / 34 files). Any entry that
|
||||
/// would need to be ADDED here is a regression: return a typed DTO instead.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> HelperBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// EMPTY since 2026-08-31 — every anonymous success WriteAsJsonAsync body is typed (the
|
||||
/// migration started from the frozen 2026-08-30 baseline of 134 sites / 76 files). Any
|
||||
/// entry that would need to be ADDED here is a regression: return a typed DTO instead.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
|
||||
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
|
||||
// response ship untyped anyway and are ratcheted here with EMPTY baselines:
|
||||
// C: the literal parked in a variable first (`var result = new { ... };` →
|
||||
// `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration,
|
||||
// D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`),
|
||||
// E: builder methods declared `object` / `Task<object>` returning `new { ... }`
|
||||
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
|
||||
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
|
||||
|
||||
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
|
||||
private static readonly Regex WriteAsJsonIdentifier =
|
||||
new(@"WriteAsJsonAsync\(\s*([A-Za-z_]\w*)\s*[,)]", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SerializeAnonymous =
|
||||
new(@"JsonSerializer\.Serialize(?:<[^>]+>)?\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex ObjectReturningBuilder =
|
||||
new(@"\b(?:object|Task<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
|
||||
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is
|
||||
/// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt.
|
||||
/// Everything else must be a typed DTO (error shapes are tolerated by the shape check).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
|
||||
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
|
||||
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
|
||||
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
file => AnonymousHelperCall.Matches(file).Count,
|
||||
HelperBaseline,
|
||||
"anonymous OkAsync/CreatedAsync/JsonAsync(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_WriteAsJsonAsync_success_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => AnonymousWriteAsJson.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
WriteBaseline,
|
||||
"anonymous success WriteAsJsonAsync(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => WriteAsJsonIdentifier.Matches(text).Count(m =>
|
||||
{
|
||||
var ident = m.Groups[1].Value;
|
||||
if (ident == "new") return false;
|
||||
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
|
||||
var a = assign.Match(text);
|
||||
// Tolerate error-shaped variables the same way inline literals are tolerated.
|
||||
return a.Success && !IsErrorShape(text, a.Index + a.Length);
|
||||
}),
|
||||
IdentifierBaseline,
|
||||
"WriteAsJsonAsync(<variable holding an anonymous object>)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => SerializeAnonymous.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
SerializeBaseline,
|
||||
"anonymous success JsonSerializer.Serialize(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Object_returning_response_builders_are_flagged()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => ObjectReturningBuilder.Matches(text).Count,
|
||||
ObjectBuilderBaseline,
|
||||
"object/Task<object>-returning Build*/Compute*/*Payload/*Response method");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
|
||||
/// properties, so any base class would silently reorder JSON keys — and key order is part
|
||||
/// of the wire contract (MCP hands raw JSON to an LLM). Declaration order == wire order.
|
||||
/// DTOs also must live in the Shared assembly, where SharedManifestParityTests exports them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_IApiResponse_implementer_is_flat_and_lives_in_Shared()
|
||||
{
|
||||
var sharedAssembly = typeof(IApiResponse).Assembly;
|
||||
var functionsAssembly = typeof(AutopilotMonitor.Functions.Helpers.ResponseHelper).Assembly;
|
||||
|
||||
var strays = functionsAssembly.GetTypes()
|
||||
.Where(t => typeof(IApiResponse).IsAssignableFrom(t) && !t.IsInterface)
|
||||
.Select(t => t.FullName)
|
||||
.ToList();
|
||||
Assert.True(strays.Count == 0,
|
||||
"IApiResponse implementers must live in AutopilotMonitor.Shared (manifest export):\n "
|
||||
+ string.Join("\n ", strays));
|
||||
|
||||
var nonFlat = sharedAssembly.GetTypes()
|
||||
.Where(t => typeof(IApiResponse).IsAssignableFrom(t) && !t.IsInterface)
|
||||
.Where(t => t.BaseType != typeof(object))
|
||||
.Select(t => $"{t.FullName} : {t.BaseType?.FullName}")
|
||||
.ToList();
|
||||
Assert.True(nonFlat.Count == 0,
|
||||
"IApiResponse implementers must derive directly from object (key-order protection):\n "
|
||||
+ string.Join("\n ", nonFlat));
|
||||
}
|
||||
|
||||
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
|
||||
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
|
||||
// wire type carrying an `object` (or collection-of-object) property hides that slot's
|
||||
// shape from the manifest exactly like an anonymous body would. `[ProjectedItems]` slots
|
||||
// (fields=-projections) and dictionary VALUES (heterogeneous bags by design, e.g.
|
||||
// HealthCheck.Details) are exempt.
|
||||
|
||||
/// <summary>
|
||||
/// The deliberately-open object slots. Every other object-typed property on a reachable
|
||||
/// wire type is a regression: give the slot a concrete Shared type.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ObjectSlotBaseline = new(StringComparer.Ordinal)
|
||||
{
|
||||
// Matched: heterogeneous evidence dictionary; not matched: the evaluator's reason string.
|
||||
"AutopilotMonitor.Shared.Models.RuleDryRunCondition.Evidence",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void No_reachable_wire_type_property_is_object_typed_outside_the_baseline()
|
||||
{
|
||||
var shared = typeof(IApiResponse).Assembly;
|
||||
var roots = shared.GetTypes().Where(t =>
|
||||
!t.IsInterface && !t.IsAbstract &&
|
||||
(typeof(IApiResponse).IsAssignableFrom(t) ||
|
||||
t.GetCustomAttributes(typeof(WireContractAttribute), inherit: false).Length > 0));
|
||||
|
||||
var seen = new HashSet<Type>();
|
||||
var queue = new Queue<Type>(roots);
|
||||
var offenders = new List<string>();
|
||||
var baselineHits = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var type = queue.Dequeue();
|
||||
if (!seen.Add(type))
|
||||
continue;
|
||||
|
||||
foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
|
||||
{
|
||||
if (prop.GetCustomAttributes(typeof(ProjectedItemsAttribute), inherit: false).Length > 0)
|
||||
continue; // fields=-projection slot: IReadOnlyList<object> by design.
|
||||
|
||||
var slotType = prop.PropertyType;
|
||||
var elementType = EnumerableElementType(slotType);
|
||||
var effective = elementType ?? slotType;
|
||||
|
||||
if (effective == typeof(object))
|
||||
{
|
||||
var slot = $"{type.FullName}.{prop.Name}";
|
||||
if (ObjectSlotBaseline.Contains(slot))
|
||||
baselineHits.Add(slot);
|
||||
else
|
||||
offenders.Add(slot);
|
||||
}
|
||||
else if (effective.Assembly == shared && effective.IsClass && effective != typeof(string))
|
||||
{
|
||||
queue.Enqueue(effective);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"object-typed properties on reachable wire types (shape invisible to the manifest) — "
|
||||
+ "give the slot a concrete Shared type or document it in ObjectSlotBaseline:\n "
|
||||
+ string.Join("\n ", offenders.OrderBy(s => s, StringComparer.Ordinal)));
|
||||
|
||||
// Ratchet down: a baseline entry whose slot no longer exists (or is no longer object)
|
||||
// must be removed, so the documented-debt list stays truthful.
|
||||
var stale = ObjectSlotBaseline.Except(baselineHits).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"ObjectSlotBaseline entries that no longer match an object-typed slot — remove them:\n "
|
||||
+ string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Element type of a generic IEnumerable<T> slot (arrays included), EXCEPT dictionaries —
|
||||
/// dictionary values are heterogeneous bags by design and stay exempt. Null for scalars.
|
||||
/// </summary>
|
||||
private static Type? EnumerableElementType(Type type)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
return null;
|
||||
if (type.IsArray)
|
||||
return type.GetElementType();
|
||||
var enumerable = new[] { type }.Concat(type.GetInterfaces())
|
||||
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
|
||||
if (enumerable == null)
|
||||
return null;
|
||||
var element = enumerable.GetGenericArguments()[0];
|
||||
// KeyValuePair element ⇒ the slot is a dictionary; values are exempt by design.
|
||||
if (element.IsGenericType && element.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
|
||||
return null;
|
||||
return element;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
|
||||
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
|
||||
/// including <c>success = someExpression</c> (dual success/failure sites) — counts as a
|
||||
/// success body that must become a typed DTO.
|
||||
/// </summary>
|
||||
private static bool IsErrorShape(string text, int afterBraceIndex)
|
||||
{
|
||||
var i = SkipTrivia(text, afterBraceIndex);
|
||||
var start = i;
|
||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_'))
|
||||
i++;
|
||||
var identifier = text.Substring(start, i - start);
|
||||
|
||||
if (identifier is "error" or "message")
|
||||
return true;
|
||||
|
||||
if (identifier == "success")
|
||||
{
|
||||
i = SkipTrivia(text, i);
|
||||
if (i < text.Length && text[i] == '=' && (i + 1 >= text.Length || text[i + 1] != '='))
|
||||
{
|
||||
i = SkipTrivia(text, i + 1);
|
||||
if (string.CompareOrdinal(text, i, "false", 0, 5) == 0 &&
|
||||
(i + 5 >= text.Length || !char.IsLetterOrDigit(text[i + 5])))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int SkipTrivia(string text, int i)
|
||||
{
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(text[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '/')
|
||||
{
|
||||
while (i < text.Length && text[i] != '\n') i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
private static void AssertRatchet(
|
||||
Func<string, int> countSites, Dictionary<string, int> baseline, string label)
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var count = countSites(File.ReadAllText(file));
|
||||
if (count > 0)
|
||||
actual[relative] = count;
|
||||
}
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!baseline.TryGetValue(file, out var allowed))
|
||||
violations.Add($"{file}: {count} NEW {label} site(s) — return a typed response DTO instead.");
|
||||
else if (count > allowed)
|
||||
violations.Add($"{file}: {count} {label} site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
|
||||
}
|
||||
|
||||
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
|
||||
// list stays truthful (and its shrink is visible in the diff).
|
||||
foreach (var (file, allowed) in baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
actual.TryGetValue(file, out var count);
|
||||
if (count < allowed)
|
||||
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(TypedResponseGuardTests)} (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
$"Anonymous {label} baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
|
||||
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
|
||||
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire);
|
||||
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire);
|
||||
|
||||
[Fact]
|
||||
public void Response_sums_the_window_and_computes_share_rate_and_trend()
|
||||
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
|
||||
[InlineData("500", 180)]
|
||||
[InlineData("14", 14)]
|
||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Vulnerability function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides. Item classes that
|
||||
/// were ALREADY typed before the migration (auto-resolve items, CPE mapping rows, unmatched
|
||||
/// aggregation rows) moved verbatim to Shared and are used on both sides — the envelope keys
|
||||
/// are the independent fixture there.
|
||||
/// </summary>
|
||||
public class VulnerabilityWireParityTests
|
||||
{
|
||||
// ---- AutoResolveCpeMapping -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutoResolveCpeMappingResponse_matches_the_bulk_outcome_shape()
|
||||
{
|
||||
var resolved = new List<AutoResolveResultItem>
|
||||
{
|
||||
new AutoResolveResultItem { SoftwareName = "7-Zip 24.08 (x64)", CpeUri = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*", Confidence = 0.92 },
|
||||
};
|
||||
var failed = new List<AutoResolveFailedItem>
|
||||
{
|
||||
new AutoResolveFailedItem { SoftwareName = "Contoso LOB Client", Reason = "no_match" },
|
||||
new AutoResolveFailedItem { SoftwareName = "", Reason = "empty_name" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
resolved,
|
||||
failed,
|
||||
totalProcessed = resolved.Count + failed.Count,
|
||||
totalResolved = resolved.Count,
|
||||
totalFailed = failed.Count
|
||||
},
|
||||
new AutoResolveCpeMappingResponse
|
||||
{
|
||||
Resolved = resolved,
|
||||
Failed = failed,
|
||||
TotalProcessed = resolved.Count + failed.Count,
|
||||
TotalResolved = resolved.Count,
|
||||
TotalFailed = failed.Count
|
||||
});
|
||||
}
|
||||
|
||||
// ---- DeleteCustomCpeMapping ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeleteCustomCpeMapping_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true, message = "CPE mapping deleted" },
|
||||
new SuccessMessageResponse { Success = true, Message = "CPE mapping deleted" });
|
||||
}
|
||||
|
||||
// ---- GetCpeMappings ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetCpeMappingsResponse_matches_the_mapping_listing_shape()
|
||||
{
|
||||
var sorted = new List<CpeMappingItem>
|
||||
{
|
||||
new CpeMappingItem
|
||||
{
|
||||
NormalizedVendor = "igor pavlov",
|
||||
NormalizedProduct = "7-zip",
|
||||
CpeVendor = "7-zip",
|
||||
CpeProduct = "7-zip",
|
||||
CpeUri = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*",
|
||||
Category = "archiver",
|
||||
DisplayNamePatterns = new List<string> { "7-zip*" },
|
||||
PublisherPatterns = new List<string> { "igor pavlov" },
|
||||
ExcludePatterns = new List<string>(),
|
||||
Source = "custom",
|
||||
CreatedAt = "2026-08-29T18:30:00.0000000Z",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { mappings = sorted, total = sorted.Count },
|
||||
new GetCpeMappingsResponse { Mappings = sorted, Total = sorted.Count });
|
||||
}
|
||||
|
||||
// ---- GetSoftwareInventory / GetTenantSoftwareInventory (same shape) ------------------
|
||||
|
||||
[Fact]
|
||||
public void SoftwareInventoryResponse_matches_the_inventory_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var inventory = new List<Dictionary<string, object>>
|
||||
{
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["displayName"] = "7-Zip 24.08 (x64)",
|
||||
["publisher"] = "Igor Pavlov",
|
||||
["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:*:*:*:*:*:*:*:*",
|
||||
["sessionCount"] = 12,
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["displayName"] = "Contoso LOB Client",
|
||||
["publisher"] = "Contoso",
|
||||
["cpeUri"] = "",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
total = inventory.Count,
|
||||
matched = inventory.Count(i => !string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
|
||||
unmatched = inventory.Count(i => string.IsNullOrEmpty(i["cpeUri"]?.ToString())),
|
||||
inventory
|
||||
},
|
||||
new SoftwareInventoryResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = tenantId,
|
||||
Total = inventory.Count,
|
||||
Matched = 1,
|
||||
Unmatched = 1,
|
||||
Inventory = inventory
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetUnmatchedSoftware ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetUnmatchedSoftwareResponse_matches_the_paged_report_shape()
|
||||
{
|
||||
var page = new List<UnmatchedSoftwareItem>
|
||||
{
|
||||
new UnmatchedSoftwareItem
|
||||
{
|
||||
SoftwareName = "Contoso LOB Client",
|
||||
Publisher = "Contoso",
|
||||
Frequency = 9,
|
||||
LastSeenAt = "2026-08-30T07:15:00.0000000Z",
|
||||
ExampleSessionId = "0b6f7a37-1111-4d61-9c93-0aa111111111",
|
||||
NormalizedVendor = "contoso",
|
||||
NormalizedVersion = "3.1.4",
|
||||
},
|
||||
};
|
||||
var total = 41;
|
||||
var skip = 0;
|
||||
var take = 50;
|
||||
|
||||
AssertParity(
|
||||
new { software = page, total, skip, take },
|
||||
new GetUnmatchedSoftwareResponse { Software = page, Total = total, Skip = skip, Take = take });
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilityReport (4 sites) ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilityReportResponse_matches_the_rescan_no_inventory_shape()
|
||||
{
|
||||
var sessionId = "3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, report = (object?)null, rescanned = true, message = "No software inventory events found for this session" },
|
||||
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null, Rescanned = true, Message = "No software inventory events found for this session" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilityReportResponse_matches_the_rescan_fresh_report_shape_and_omits_message()
|
||||
{
|
||||
var sessionId = "3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002";
|
||||
var freshReport = new Dictionary<string, object>
|
||||
{
|
||||
["totalFindings"] = 2,
|
||||
["scannedAt"] = "2026-08-30T12:00:00.0000000Z",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, report = freshReport, rescanned = true },
|
||||
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = freshReport, Rescanned = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilityReportResponse_matches_the_rescan_no_findings_shape()
|
||||
{
|
||||
var sessionId = "3f1c9d55-cccc-4e0e-8888-52f3aaaa0003";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, report = (object?)null, rescanned = true, message = "Re-scan complete: no vulnerabilities found" },
|
||||
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null, Rescanned = true, Message = "Re-scan complete: no vulnerabilities found" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilityReportResponse_matches_the_no_stored_report_shape_and_omits_rescanned()
|
||||
{
|
||||
var sessionId = "3f1c9d55-dddd-4e0e-8888-52f3aaaa0004";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, report = (object?)null },
|
||||
new GetVulnerabilityReportResponse { Success = true, SessionId = sessionId, Report = null });
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilitySyncStatus ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilitySyncStatusResponse_matches_the_fully_populated_shape()
|
||||
{
|
||||
var nvdCacheEntries = 1543;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kevCatalogEntries = 1312,
|
||||
kevLastSyncUtc = (string?)"2026-08-30T04:00:01.0000000Z",
|
||||
msrcIndexedCves = 4211,
|
||||
msrcCoveredDocuments = 36,
|
||||
msrcLastSyncUtc = (string?)"2026-08-30T04:00:05.0000000Z",
|
||||
nvdCacheEntries,
|
||||
nvdCacheLastRefreshUtc = (string?)"2026-08-30T04:10:00.0000000Z",
|
||||
nvdRefreshRunning = false,
|
||||
nvdLastRun = new
|
||||
{
|
||||
stale = 40, refreshed = 38, failed = 2,
|
||||
skippedThrottled = 0, error = (string?)"NVD returned 503 for 2 CPEs",
|
||||
startedUtc = "2026-08-30T04:00:10.0000000Z", finishedUtc = (string?)"2026-08-30T04:10:00.0000000Z",
|
||||
},
|
||||
epssLastSyncUtc = (string?)"2026-08-30T04:11:00.0000000Z",
|
||||
epssRefreshRunning = false,
|
||||
epssLastRun = new
|
||||
{
|
||||
rows = 1543, cves = 1490, scored = 1450,
|
||||
rowsRewritten = 1201, error = (string?)"EPSS batch 3 timed out",
|
||||
startedUtc = "2026-08-30T04:10:30.0000000Z", finishedUtc = (string?)"2026-08-30T04:11:00.0000000Z",
|
||||
},
|
||||
},
|
||||
new GetVulnerabilitySyncStatusResponse
|
||||
{
|
||||
KevCatalogEntries = 1312,
|
||||
KevLastSyncUtc = "2026-08-30T04:00:01.0000000Z",
|
||||
MsrcIndexedCves = 4211,
|
||||
MsrcCoveredDocuments = 36,
|
||||
MsrcLastSyncUtc = "2026-08-30T04:00:05.0000000Z",
|
||||
NvdCacheEntries = nvdCacheEntries,
|
||||
NvdCacheLastRefreshUtc = "2026-08-30T04:10:00.0000000Z",
|
||||
NvdRefreshRunning = false,
|
||||
NvdLastRun = new NvdRefreshRunWire
|
||||
{
|
||||
Stale = 40, Refreshed = 38, Failed = 2,
|
||||
SkippedThrottled = 0, Error = "NVD returned 503 for 2 CPEs",
|
||||
StartedUtc = "2026-08-30T04:00:10.0000000Z", FinishedUtc = "2026-08-30T04:10:00.0000000Z",
|
||||
},
|
||||
EpssLastSyncUtc = "2026-08-30T04:11:00.0000000Z",
|
||||
EpssRefreshRunning = false,
|
||||
EpssLastRun = new EpssRefreshRunWire
|
||||
{
|
||||
Rows = 1543, Cves = 1490, Scored = 1450,
|
||||
RowsRewritten = 1201, Error = "EPSS batch 3 timed out",
|
||||
StartedUtc = "2026-08-30T04:10:30.0000000Z", FinishedUtc = "2026-08-30T04:11:00.0000000Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilitySyncStatusResponse_omits_all_null_slots_on_a_cold_instance()
|
||||
{
|
||||
// config == null (no AdminConfiguration yet) and no in-memory runs: every
|
||||
// nullable slot vanishes on both sides.
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kevCatalogEntries = 0,
|
||||
kevLastSyncUtc = (string?)null,
|
||||
msrcIndexedCves = 0,
|
||||
msrcCoveredDocuments = 0,
|
||||
msrcLastSyncUtc = (string?)null,
|
||||
nvdCacheEntries = 0,
|
||||
nvdCacheLastRefreshUtc = (string?)null,
|
||||
nvdRefreshRunning = false,
|
||||
nvdLastRun = (object?)null,
|
||||
epssLastSyncUtc = (string?)null,
|
||||
epssRefreshRunning = false,
|
||||
epssLastRun = (object?)null,
|
||||
},
|
||||
new GetVulnerabilitySyncStatusResponse
|
||||
{
|
||||
KevCatalogEntries = 0,
|
||||
KevLastSyncUtc = null,
|
||||
MsrcIndexedCves = 0,
|
||||
MsrcCoveredDocuments = 0,
|
||||
MsrcLastSyncUtc = null,
|
||||
NvdCacheEntries = 0,
|
||||
NvdCacheLastRefreshUtc = null,
|
||||
NvdRefreshRunning = false,
|
||||
NvdLastRun = null,
|
||||
EpssLastSyncUtc = null,
|
||||
EpssRefreshRunning = false,
|
||||
EpssLastRun = null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVulnerabilitySyncStatusResponse_omits_null_error_and_finishedUtc_inside_running_runs()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kevCatalogEntries = 1312,
|
||||
kevLastSyncUtc = (string?)"2026-08-30T04:00:01.0000000Z",
|
||||
msrcIndexedCves = 4211,
|
||||
msrcCoveredDocuments = 36,
|
||||
msrcLastSyncUtc = (string?)"2026-08-30T04:00:05.0000000Z",
|
||||
nvdCacheEntries = 1543,
|
||||
nvdCacheLastRefreshUtc = (string?)null,
|
||||
nvdRefreshRunning = true,
|
||||
nvdLastRun = new
|
||||
{
|
||||
stale = 40, refreshed = 12, failed = 0,
|
||||
skippedThrottled = 0, error = (string?)null,
|
||||
startedUtc = "2026-08-30T12:00:00.0000000Z", finishedUtc = (string?)null,
|
||||
},
|
||||
epssLastSyncUtc = (string?)null,
|
||||
epssRefreshRunning = true,
|
||||
epssLastRun = new
|
||||
{
|
||||
rows = 0, cves = 0, scored = 0,
|
||||
rowsRewritten = 0, error = (string?)null,
|
||||
startedUtc = "2026-08-30T12:01:00.0000000Z", finishedUtc = (string?)null,
|
||||
},
|
||||
},
|
||||
new GetVulnerabilitySyncStatusResponse
|
||||
{
|
||||
KevCatalogEntries = 1312,
|
||||
KevLastSyncUtc = "2026-08-30T04:00:01.0000000Z",
|
||||
MsrcIndexedCves = 4211,
|
||||
MsrcCoveredDocuments = 36,
|
||||
MsrcLastSyncUtc = "2026-08-30T04:00:05.0000000Z",
|
||||
NvdCacheEntries = 1543,
|
||||
NvdCacheLastRefreshUtc = null,
|
||||
NvdRefreshRunning = true,
|
||||
NvdLastRun = new NvdRefreshRunWire
|
||||
{
|
||||
Stale = 40, Refreshed = 12, Failed = 0,
|
||||
SkippedThrottled = 0, Error = null,
|
||||
StartedUtc = "2026-08-30T12:00:00.0000000Z", FinishedUtc = null,
|
||||
},
|
||||
EpssLastSyncUtc = null,
|
||||
EpssRefreshRunning = true,
|
||||
EpssLastRun = new EpssRefreshRunWire
|
||||
{
|
||||
Rows = 0, Cves = 0, Scored = 0,
|
||||
RowsRewritten = 0, Error = null,
|
||||
StartedUtc = "2026-08-30T12:01:00.0000000Z", FinishedUtc = null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- IgnoreSoftware (GET / POST / DELETE) --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetIgnoredSoftwareResponse_matches_the_ignore_list_shape()
|
||||
{
|
||||
// Old site built anonymous items { softwareName, publisher, reason, ignoredAt }.
|
||||
var anonymousItems = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
softwareName = "contoso lob client",
|
||||
publisher = "contoso",
|
||||
reason = "internal tool",
|
||||
ignoredAt = "2026-08-28T09:00:00.0000000Z",
|
||||
},
|
||||
};
|
||||
var typedItems = new List<IgnoredSoftwareItem>
|
||||
{
|
||||
new IgnoredSoftwareItem
|
||||
{
|
||||
SoftwareName = "contoso lob client",
|
||||
Publisher = "contoso",
|
||||
Reason = "internal tool",
|
||||
IgnoredAt = "2026-08-28T09:00:00.0000000Z",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { items = anonymousItems, total = anonymousItems.Count },
|
||||
new GetIgnoredSoftwareResponse { Items = typedItems, Total = typedItems.Count });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddIgnoredSoftwareResponse_matches_the_added_count_shape()
|
||||
{
|
||||
var added = 3;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, added },
|
||||
new AddIgnoredSoftwareResponse { Success = true, Added = added });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteIgnoredSoftware_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true, message = "Removed from ignore list" },
|
||||
new SuccessMessageResponse { Success = true, Message = "Removed from ignore list" });
|
||||
}
|
||||
|
||||
// ---- SaveCustomCpeMapping ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SaveCustomCpeMappingResponse_matches_the_saved_mapping_shape()
|
||||
{
|
||||
var rowKey = "igor pavlov:7-zip";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, message = "CPE mapping saved", rowKey },
|
||||
new SaveCustomCpeMappingResponse { Success = true, Message = "CPE mapping saved", RowKey = rowKey });
|
||||
}
|
||||
|
||||
// ---- TriggerEpssSync (dual-purpose success) ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerEpssSyncResponse_matches_the_failed_run_shape()
|
||||
{
|
||||
var ok = false;
|
||||
string? error = "EPSS returned 500";
|
||||
var durationMs = 5230;
|
||||
var syncedAt = "2026-08-30T12:05:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = ok,
|
||||
message = $"EPSS sync failed: {error}",
|
||||
epssRows = 0,
|
||||
epssCves = 0,
|
||||
epssScored = 0,
|
||||
epssRowsRewritten = 0,
|
||||
epssError = error,
|
||||
durationMs,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerEpssSyncResponse
|
||||
{
|
||||
Success = ok,
|
||||
Message = $"EPSS sync failed: {error}",
|
||||
EpssRows = 0,
|
||||
EpssCves = 0,
|
||||
EpssScored = 0,
|
||||
EpssRowsRewritten = 0,
|
||||
EpssError = error,
|
||||
DurationMs = durationMs,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerEpssSyncResponse_omits_a_null_epssError_on_success()
|
||||
{
|
||||
var durationMs = 4100;
|
||||
var syncedAt = "2026-08-30T12:06:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "EPSS scores refreshed: 1,450 of 1,490 cached CVEs scored, 1,201 rows updated",
|
||||
epssRows = 1543,
|
||||
epssCves = 1490,
|
||||
epssScored = 1450,
|
||||
epssRowsRewritten = 1201,
|
||||
epssError = (string?)null,
|
||||
durationMs,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerEpssSyncResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "EPSS scores refreshed: 1,450 of 1,490 cached CVEs scored, 1,201 rows updated",
|
||||
EpssRows = 1543,
|
||||
EpssCves = 1490,
|
||||
EpssScored = 1450,
|
||||
EpssRowsRewritten = 1201,
|
||||
EpssError = null,
|
||||
DurationMs = durationMs,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TriggerMsrcSync (dual-purpose success) ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerMsrcSyncResponse_matches_the_failed_run_shape()
|
||||
{
|
||||
string? error = "CVRF document 2026-Aug not reachable";
|
||||
var durationMs = 8000;
|
||||
var syncedAt = "2026-08-30T12:07:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = string.IsNullOrEmpty(error),
|
||||
message = $"MSRC sync failed: {error}",
|
||||
msrcCves = 0,
|
||||
msrcDocs = 2,
|
||||
msrcDocsFailed = 1,
|
||||
msrcSkipped = false,
|
||||
msrcError = error,
|
||||
durationMs,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerMsrcSyncResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = $"MSRC sync failed: {error}",
|
||||
MsrcCves = 0,
|
||||
MsrcDocs = 2,
|
||||
MsrcDocsFailed = 1,
|
||||
MsrcSkipped = false,
|
||||
MsrcError = error,
|
||||
DurationMs = durationMs,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerMsrcSyncResponse_omits_a_null_msrcError_on_success()
|
||||
{
|
||||
var durationMs = 6500;
|
||||
var syncedAt = "2026-08-30T12:08:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "MSRC index refreshed",
|
||||
msrcCves = 4211,
|
||||
msrcDocs = 36,
|
||||
msrcDocsFailed = 0,
|
||||
msrcSkipped = false,
|
||||
msrcError = (string?)null,
|
||||
durationMs,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerMsrcSyncResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "MSRC index refreshed",
|
||||
MsrcCves = 4211,
|
||||
MsrcDocs = 36,
|
||||
MsrcDocsFailed = 0,
|
||||
MsrcSkipped = false,
|
||||
MsrcError = null,
|
||||
DurationMs = durationMs,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TriggerNvdCacheRefresh ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerNvdCacheRefreshResponse_matches_the_accepted_shape()
|
||||
{
|
||||
var startedAt = "2026-08-30T12:09:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "NVD cache refresh started — stale entries are re-queried sequentially; check the sync status for progress",
|
||||
startedAt
|
||||
},
|
||||
new TriggerNvdCacheRefreshResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "NVD cache refresh started — stale entries are re-queried sequentially; check the sync status for progress",
|
||||
StartedAt = startedAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TriggerVulnerabilityDataSync ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerVulnerabilityDataSyncResponse_matches_the_reseed_shape_with_errors()
|
||||
{
|
||||
var cpeSeedEntries = 0;
|
||||
string? cpeSeedError = "GitHub fetch failed: 403";
|
||||
var cpeCommunityEntries = 0;
|
||||
string? cpeCommunityError = "No community mappings available: 404";
|
||||
var syncedAt = "2026-08-30T12:10:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Vulnerability data sync completed",
|
||||
kevCatalogEntries = 1312,
|
||||
cpeSeedEntries,
|
||||
cpeSeedError = cpeSeedError,
|
||||
cpeCommunityEntries,
|
||||
cpeCommunityError = cpeCommunityError,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerVulnerabilityDataSyncResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Vulnerability data sync completed",
|
||||
KevCatalogEntries = 1312,
|
||||
CpeSeedEntries = cpeSeedEntries,
|
||||
CpeSeedError = cpeSeedError,
|
||||
CpeCommunityEntries = cpeCommunityEntries,
|
||||
CpeCommunityError = cpeCommunityError,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerVulnerabilityDataSyncResponse_omits_null_reseed_errors()
|
||||
{
|
||||
var cpeSeedEntries = 180;
|
||||
string? cpeSeedError = null;
|
||||
var cpeCommunityEntries = 12;
|
||||
string? cpeCommunityError = null;
|
||||
var syncedAt = "2026-08-30T12:11:00.0000000Z";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Vulnerability data sync completed",
|
||||
kevCatalogEntries = 1312,
|
||||
cpeSeedEntries,
|
||||
cpeSeedError = cpeSeedError,
|
||||
cpeCommunityEntries,
|
||||
cpeCommunityError = cpeCommunityError,
|
||||
syncedAt
|
||||
},
|
||||
new TriggerVulnerabilityDataSyncResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Vulnerability data sync completed",
|
||||
KevCatalogEntries = 1312,
|
||||
CpeSeedEntries = cpeSeedEntries,
|
||||
CpeSeedError = null,
|
||||
CpeCommunityEntries = cpeCommunityEntries,
|
||||
CpeCommunityError = null,
|
||||
SyncedAt = syncedAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary -------------------------
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous);
|
||||
// it only gained the IApiResponse marker. The anonymous side here replicates the record's
|
||||
// established wire shape to pin key names, order and the TotalAffectedTenants omission.
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var topCves = new List<TopCve>
|
||||
{
|
||||
new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-12345",
|
||||
CvssSeverity = "critical",
|
||||
CvssScore = 9.8,
|
||||
IsKev = true,
|
||||
EpssScore = 0.94,
|
||||
Priority = "act",
|
||||
AffectedSessions = 7,
|
||||
SampleSoftware = new List<string> { "7-Zip 24.08 (x64)" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
windowDays = 30,
|
||||
totalAffectedSessions = 11,
|
||||
totalAffectedTenants = (int?)3,
|
||||
distinctCves = 3,
|
||||
kevCves = 1,
|
||||
severityBreakdown = breakdown,
|
||||
topCves,
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
{
|
||||
WindowDays = 30,
|
||||
TotalAffectedSessions = 11,
|
||||
TotalAffectedTenants = 3,
|
||||
DistinctCves = 3,
|
||||
KevCves = 1,
|
||||
SeverityBreakdown = breakdown,
|
||||
TopCves = topCves,
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
windowDays = 7,
|
||||
totalAffectedSessions = 0,
|
||||
totalAffectedTenants = (int?)null,
|
||||
distinctCves = 0,
|
||||
kevCves = 0,
|
||||
severityBreakdown = breakdown,
|
||||
topCves,
|
||||
truncated = true,
|
||||
},
|
||||
new CveExposureSummary
|
||||
{
|
||||
WindowDays = 7,
|
||||
TotalAffectedSessions = 0,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 0,
|
||||
KevCves = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
TopCves = topCves,
|
||||
Truncated = true,
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>types</c> section of shared-manifests.json (schemaVersion 2): a
|
||||
/// machine-readable description of every HTTP wire type in AutopilotMonitor.Shared —
|
||||
/// roots are all <see cref="IApiResponse"/> implementers plus every
|
||||
/// <see cref="WireContractAttribute"/>-marked type, closed transitively over
|
||||
/// references, collections, dictionaries, enums and <see cref="ProjectedItemsAttribute"/>
|
||||
/// item types. The web generator (scripts/generate-shared-manifest-types.js) turns it
|
||||
/// into utils/wire-types.generated.ts.
|
||||
///
|
||||
/// Field names honour <see cref="JsonPropertyNameAttribute"/> and fall back to the
|
||||
/// camelCase policy; <see cref="JsonIgnoreAttribute"/> members are skipped; a nullable
|
||||
/// slot is exported <c>optional</c> (WhenWritingNull omits its key); get-only members are
|
||||
/// <c>readonly</c>. Unmappable shapes (non-string dictionary keys, foreign BCL classes)
|
||||
/// FAIL the build loudly instead of degrading to <c>unknown</c> — an unknown that nobody
|
||||
/// chose is drift waiting to happen.
|
||||
/// </summary>
|
||||
internal static class WireTypeManifestBuilder
|
||||
{
|
||||
// ── public entry ────────────────────────────────────────────────────────
|
||||
|
||||
public static SortedDictionary<string, object?> BuildTypesSection()
|
||||
{
|
||||
var assembly = typeof(IApiResponse).Assembly;
|
||||
var docs = XmlDocs.Load(assembly);
|
||||
|
||||
var roots = assembly.GetTypes()
|
||||
.Where(t => !t.IsInterface && !t.IsAbstract &&
|
||||
(typeof(IApiResponse).IsAssignableFrom(t) ||
|
||||
t.GetCustomAttribute<WireContractAttribute>() != null))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var result = new SortedDictionary<string, object?>(StringComparer.Ordinal);
|
||||
var visited = new Dictionary<string, Type>(StringComparer.Ordinal);
|
||||
var queue = new Queue<Type>(roots);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var type = queue.Dequeue();
|
||||
if (visited.TryGetValue(type.Name, out var seen))
|
||||
{
|
||||
Assert.True(seen == type,
|
||||
$"Wire type simple-name collision: {seen.FullName} vs {type.FullName}. " +
|
||||
"TS interfaces are flat-named — rename one of them.");
|
||||
continue;
|
||||
}
|
||||
visited[type.Name] = type;
|
||||
|
||||
result[type.Name] = type.IsEnum
|
||||
? BuildEnum(type, docs)
|
||||
: BuildObject(type, docs, queue);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── object / enum shapes ────────────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, object?> BuildEnum(Type type, XmlDocs docs)
|
||||
{
|
||||
var node = new Dictionary<string, object?> { ["kind"] = "enum" };
|
||||
var doc = docs.Summary("T:" + type.FullName);
|
||||
if (doc != null) node["doc"] = doc;
|
||||
// Declaration order kept — string-enum unions have no ordinal semantics on the
|
||||
// wire (JsonStringEnumConverter), but a stable order keeps diffs minimal.
|
||||
node["members"] = Enum.GetNames(type);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildObject(Type type, XmlDocs docs, Queue<Type> queue)
|
||||
{
|
||||
var node = new Dictionary<string, object?> { ["kind"] = "object" };
|
||||
var typeDoc = docs.Summary("T:" + type.FullName);
|
||||
if (typeDoc != null) node["doc"] = typeDoc;
|
||||
|
||||
var nullability = new NullabilityInfoContext();
|
||||
var fields = new List<object?>();
|
||||
|
||||
// System.Text.Json serializes the DERIVED type's declared properties before the base
|
||||
// type's — walk the hierarchy most-derived-first so field order == wire order even for
|
||||
// the legacy inherited wire types (LocationSessionRow : SessionSummary). New DTOs stay
|
||||
// flat (TypedResponseGuardTests enforces that for every IApiResponse envelope).
|
||||
var properties = new List<PropertyInfo>();
|
||||
for (var t = type; t != null && t != typeof(object); t = t.BaseType)
|
||||
{
|
||||
Assert.True(t == type || t.Assembly == typeof(IApiResponse).Assembly,
|
||||
$"{type.FullName}: base type {t.FullName} is outside AutopilotMonitor.Shared.");
|
||||
properties.AddRange(t
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(p => p.GetMethod != null && p.GetMethod.IsPublic && p.GetIndexParameters().Length == 0)
|
||||
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>() is not { Condition: JsonIgnoreCondition.Always })
|
||||
.OrderBy(p => p.MetadataToken)); // declaration order == wire order
|
||||
}
|
||||
|
||||
foreach (var p in properties)
|
||||
{
|
||||
var field = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
|
||||
?? JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
};
|
||||
|
||||
var projected = p.GetCustomAttribute<ProjectedItemsAttribute>();
|
||||
if (projected != null)
|
||||
{
|
||||
queue.Enqueue(projected.ItemType);
|
||||
field["type"] = new Dictionary<string, object?>
|
||||
{
|
||||
["kind"] = "array",
|
||||
["element"] = new Dictionary<string, object?>
|
||||
{
|
||||
["kind"] = "partial",
|
||||
["name"] = projected.ItemType.Name,
|
||||
},
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var info = nullability.Create(p);
|
||||
var descriptor = Describe(info.Type, info, queue, $"{type.Name}.{p.Name}");
|
||||
// Property-level null never reaches the wire (WhenWritingNull omits the KEY —
|
||||
// that's what "optional" says). Explicit nulls only exist INSIDE collections
|
||||
// and dictionary values, so the flag stays on nested descriptors only.
|
||||
descriptor.Remove("nullable");
|
||||
field["type"] = descriptor;
|
||||
}
|
||||
|
||||
if (IsOptional(p, nullability)) field["optional"] = true;
|
||||
if (p.SetMethod == null || !p.SetMethod.IsPublic) field["readonly"] = true;
|
||||
|
||||
var doc = docs.Summary($"P:{type.FullName}.{p.Name}");
|
||||
if (doc != null) field["doc"] = doc;
|
||||
|
||||
fields.Add(field);
|
||||
}
|
||||
|
||||
node["fields"] = fields;
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>A nullable slot's key is ABSENT under WhenWritingNull ⇒ optional in TS.</summary>
|
||||
private static bool IsOptional(PropertyInfo p, NullabilityInfoContext ctx)
|
||||
{
|
||||
if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true;
|
||||
if (p.PropertyType.IsValueType) return false;
|
||||
return ctx.Create(p).ReadState != NullabilityState.NotNull;
|
||||
}
|
||||
|
||||
// ── type descriptors ────────────────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, object?> Describe(
|
||||
Type type, NullabilityInfo? info, Queue<Type> queue, string context)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type);
|
||||
if (underlying != null)
|
||||
{
|
||||
var inner = Describe(underlying, null, queue, context);
|
||||
inner["nullable"] = true;
|
||||
return inner;
|
||||
}
|
||||
|
||||
if (Primitive(type) is { } primitive)
|
||||
{
|
||||
var node = Descriptor("primitive", primitive);
|
||||
if (info != null && !type.IsValueType && type != typeof(object) &&
|
||||
info.ReadState == NullabilityState.Nullable)
|
||||
node["nullable"] = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
if (type.IsEnum)
|
||||
{
|
||||
queue.Enqueue(type);
|
||||
// JsonStringEnumConverter is part of the wire settings — enums travel as name strings.
|
||||
var node = Descriptor("enum", type.Name);
|
||||
return node;
|
||||
}
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
var element = Describe(type.GetElementType()!, info?.ElementType, queue, context + "[]");
|
||||
return new Dictionary<string, object?> { ["kind"] = "array", ["element"] = element };
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var args = type.GetGenericTypeArguments(info);
|
||||
if (IsDictionaryLike(type))
|
||||
{
|
||||
Assert.True(args[0].Type == typeof(string),
|
||||
$"{context}: dictionary key must be string for the JSON wire, found {args[0].Type.Name}.");
|
||||
var value = Describe(args[1].Type, args[1].Info, queue, context + "{}");
|
||||
return new Dictionary<string, object?> { ["kind"] = "map", ["value"] = value };
|
||||
}
|
||||
|
||||
if (IsEnumerableLike(type))
|
||||
{
|
||||
var element = Describe(args[0].Type, args[0].Info, queue, context + "[]");
|
||||
return new Dictionary<string, object?> { ["kind"] = "array", ["element"] = element };
|
||||
}
|
||||
}
|
||||
|
||||
if (type.Assembly == typeof(IApiResponse).Assembly && (type.IsClass || type.IsValueType))
|
||||
{
|
||||
queue.Enqueue(type);
|
||||
var node = Descriptor("ref", type.Name);
|
||||
if (info != null && info.ReadState == NullabilityState.Nullable)
|
||||
node["nullable"] = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"{context}: no wire mapping for {type.FullName}. Map it explicitly in " +
|
||||
$"{nameof(WireTypeManifestBuilder)} (or move the type into AutopilotMonitor.Shared) — " +
|
||||
"silent 'unknown' fallbacks are drift.");
|
||||
}
|
||||
|
||||
private static string? Primitive(Type type)
|
||||
{
|
||||
if (type == typeof(string) || type == typeof(char)) return "string";
|
||||
if (type == typeof(bool)) return "boolean";
|
||||
if (type == typeof(int) || type == typeof(long) || type == typeof(short) ||
|
||||
type == typeof(byte) || type == typeof(sbyte) || type == typeof(uint) ||
|
||||
type == typeof(ulong) || type == typeof(ushort) || type == typeof(double) ||
|
||||
type == typeof(float) || type == typeof(decimal)) return "number";
|
||||
// Preformatted on the wire by the serializer — TS sees strings.
|
||||
if (type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(Guid)) return "string";
|
||||
if (type == typeof(object) || type == typeof(JsonElement)) return "unknown";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Descriptor(string kind, string name)
|
||||
=> new() { ["kind"] = kind, ["name"] = name };
|
||||
|
||||
private static bool IsDictionaryLike(Type type)
|
||||
{
|
||||
var def = type.GetGenericTypeDefinition();
|
||||
if (def == typeof(Dictionary<,>) || def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>))
|
||||
return true;
|
||||
return type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>));
|
||||
}
|
||||
|
||||
private static bool IsEnumerableLike(Type type)
|
||||
=> type.GetInterfaces().Append(type).Any(i =>
|
||||
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
|
||||
|
||||
private readonly record struct GenericArg(Type Type, NullabilityInfo? Info);
|
||||
|
||||
private static GenericArg[] GetGenericTypeArguments(this Type type, NullabilityInfo? info)
|
||||
{
|
||||
var args = type.GetGenericArguments();
|
||||
var infos = info?.GenericTypeArguments;
|
||||
return args.Select((a, i) => new GenericArg(a, infos != null && i < infos.Length ? infos[i] : null))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// ── XML documentation ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary><c><summary></c> texts from AutopilotMonitor.Shared.xml, flattened to plain text.</summary>
|
||||
private sealed class XmlDocs
|
||||
{
|
||||
private readonly Dictionary<string, string> _summaries;
|
||||
|
||||
private XmlDocs(Dictionary<string, string> summaries) => _summaries = summaries;
|
||||
|
||||
public static XmlDocs Load(Assembly assembly)
|
||||
{
|
||||
var path = Path.ChangeExtension(assembly.Location, ".xml");
|
||||
Assert.True(File.Exists(path),
|
||||
$"XML doc file missing next to {assembly.GetName().Name}.dll — " +
|
||||
"GenerateDocumentationFile must stay enabled in AutopilotMonitor.Shared.csproj " +
|
||||
"(the wire-type manifest exports <summary> texts as JSDoc).");
|
||||
|
||||
var summaries = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var member in XDocument.Load(path).Descendants("member"))
|
||||
{
|
||||
var name = member.Attribute("name")?.Value;
|
||||
var summary = member.Element("summary");
|
||||
if (name == null || summary == null) continue;
|
||||
var text = Flatten(summary);
|
||||
if (text.Length > 0) summaries[name] = text;
|
||||
}
|
||||
return new XmlDocs(summaries);
|
||||
}
|
||||
|
||||
public string? Summary(string memberId)
|
||||
=> _summaries.TryGetValue(memberId, out var s) ? s : null;
|
||||
|
||||
private static string Flatten(XElement summary)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var node in summary.Nodes())
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case XText text:
|
||||
parts.Add(text.Value);
|
||||
break;
|
||||
case XElement { Name.LocalName: "see" or "seealso" } el:
|
||||
var cref = el.Attribute("cref")?.Value;
|
||||
if (!string.IsNullOrEmpty(el.Value)) parts.Add(el.Value);
|
||||
else if (cref != null) parts.Add(cref[(cref.LastIndexOf('.') + 1)..]);
|
||||
else parts.Add(el.Attribute("href")?.Value ?? "");
|
||||
break;
|
||||
case XElement { Name.LocalName: "paramref" or "typeparamref" } el:
|
||||
parts.Add(el.Attribute("name")?.Value ?? "");
|
||||
break;
|
||||
case XElement el:
|
||||
// <c>, <b>, <para>, … — keep the visible text, drop the markup.
|
||||
parts.Add(el.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Regex.Replace(string.Concat(parts), @"\s+", " ").Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -914,6 +914,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty },
|
||||
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
|
||||
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
|
||||
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
|
||||
// Per-line agent binary integrity (written by build scripts via Merge).
|
||||
// V2 is the only wired line; future V3 = add field set here. Retired columns
|
||||
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
|
||||
@@ -985,6 +986,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"),
|
||||
OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false,
|
||||
OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"),
|
||||
OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"),
|
||||
// Per-line agent binary integrity. V2 is the only wired line (V1 retired).
|
||||
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
|
||||
LatestAgentV2Version = entity.GetString("LatestAgentV2Version") ?? string.Empty,
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc)
|
||||
=> _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc);
|
||||
|
||||
public Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
public Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
=> _storage.GetMetricsSummaryAsync(tenantId, days);
|
||||
|
||||
public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
|
||||
|
||||
+53
-21
@@ -53,10 +53,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
public async Task<List<OpsEventEntry>> GetOpsEventsAsync(
|
||||
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null)
|
||||
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var result = new List<OpsEventEntry>();
|
||||
var filter = BuildFilter(category, dateFrom, dateTo);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var query = string.IsNullOrEmpty(filter)
|
||||
? _table.QueryAsync<TableEntity>()
|
||||
: _table.QueryAsync<TableEntity>(filter: filter);
|
||||
@@ -71,7 +72,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
public async Task<RawPage<OpsEventEntry>> GetOpsEventsPageAsync(
|
||||
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
@@ -80,7 +82,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
var filter = BuildFilter(category, dateFrom, dateTo);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
@@ -97,7 +99,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation);
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -106,21 +108,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed list — keep in sync with OpsEventCategory. New categories are rare
|
||||
// and require a deploy anyway, so a runtime probe of the table partitions
|
||||
// would just trade certainty for cost.
|
||||
internal static readonly string[] AllCategories = new[]
|
||||
{
|
||||
OpsEventCategory.Consent,
|
||||
OpsEventCategory.Maintenance,
|
||||
OpsEventCategory.Security,
|
||||
OpsEventCategory.Tenant,
|
||||
OpsEventCategory.Agent,
|
||||
OpsEventCategory.Sla,
|
||||
};
|
||||
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
|
||||
// once: Platform (Azure Monitor alerts) was written but never listed, so every PAGED
|
||||
// cross-category read silently skipped it while the unpaged full-table path still showed
|
||||
// it. New categories are rare and need a deploy anyway, so a runtime probe of the table
|
||||
// partitions would just trade certainty for cost — the coverage test guards the list instead.
|
||||
internal static readonly string[] AllCategories = OpsEventCategory.All;
|
||||
|
||||
private async Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation);
|
||||
// First-page request fans out across every category; subsequent
|
||||
@@ -139,7 +136,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
var fetchTasks = activeCats.Select(async cat =>
|
||||
{
|
||||
continuations.TryGetValue(cat, out var catContinuation);
|
||||
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey);
|
||||
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey, filters);
|
||||
|
||||
var fetched = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: pageSize))
|
||||
@@ -162,7 +159,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return new RawPage<OpsEventEntry>(items, nextRawToken);
|
||||
}
|
||||
|
||||
private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
|
||||
internal static string BuildFilterWithRowKeyBound(
|
||||
string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
@@ -174,13 +173,45 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
clauses.Add(BusinessTimestamp.OpsDateFromClause(ToUtc(dateFrom.Value)));
|
||||
if (dateTo.HasValue)
|
||||
clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value)));
|
||||
AppendFieldFilters(clauses, filters);
|
||||
return string.Join(" and ", clauses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the optional non-key field filters (eventType / severity / minSeverity) as
|
||||
/// SERVER-SIDE clauses. Both filter builders funnel through here so the paged fan-out and
|
||||
/// the single-category / unpaged paths can never honour a different filter surface — a
|
||||
/// drift would make the same query return different rows depending on whether a category
|
||||
/// was named. minSeverity expands to an OR-set instead of a range comparison because Table
|
||||
/// Storage compares strings lexicographically ("Critical" < "Error" < "Info" <
|
||||
/// "Warning"), which has nothing to do with severity order.
|
||||
/// </summary>
|
||||
private static void AppendFieldFilters(List<string> clauses, OpsEventQueryFilters? filters)
|
||||
{
|
||||
if (filters == null || filters.IsEmpty) return;
|
||||
if (!string.IsNullOrEmpty(filters.EventType))
|
||||
clauses.Add($"EventType eq '{filters.EventType!.Replace("'", "''")}'");
|
||||
if (!string.IsNullOrEmpty(filters.Severity))
|
||||
clauses.Add($"Severity eq '{filters.Severity!.Replace("'", "''")}'");
|
||||
if (!string.IsNullOrEmpty(filters.MinSeverity))
|
||||
{
|
||||
var allowed = OpsEventSeverity.AtOrAbove(filters.MinSeverity!);
|
||||
// Info is the floor — every canonical severity qualifies, so the clause would be a
|
||||
// no-op that only costs query length. An unknown value yields an empty set; the
|
||||
// endpoint rejects those up front (400), so this stays defensive-only.
|
||||
if (allowed.Count > 0 && allowed.Count < OpsEventSeverity.All.Length)
|
||||
{
|
||||
var terms = allowed.Select(s => $"Severity eq '{s}'");
|
||||
clauses.Add($"({string.Join(" or ", terms)})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date windows filter on the RowKey (reverse-tick, index-backed): the system
|
||||
// Timestamp is reset by storage migrations, and an OccurredUtc property filter
|
||||
// would exclude rows written before that column existed.
|
||||
internal static string? BuildFilter(string? category, DateTime? dateFrom, DateTime? dateTo)
|
||||
internal static string? BuildFilter(
|
||||
string? category, DateTime? dateFrom, DateTime? dateTo, OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var clauses = new List<string>();
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
@@ -195,6 +226,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
clauses.Add(BusinessTimestamp.OpsDateToClause(ToUtc(dateTo.Value)));
|
||||
}
|
||||
AppendFieldFilters(clauses, filters);
|
||||
return clauses.Count == 0 ? null : string.Join(" and ", clauses);
|
||||
}
|
||||
|
||||
|
||||
+13
-12
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -147,10 +148,10 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new AutopilotConsentUrlResponse
|
||||
{
|
||||
consentUrl,
|
||||
willAutoFlipHoming = funnel
|
||||
ConsentUrl = consentUrl,
|
||||
WillAutoFlipHoming = funnel
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -183,11 +184,11 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
});
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new AutopilotConsentStatusResponse
|
||||
{
|
||||
isConsented = result.IsConsented,
|
||||
message = result.Message,
|
||||
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
|
||||
IsConsented = result.IsConsented,
|
||||
Message = result.Message,
|
||||
HomingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -266,12 +267,12 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
});
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new AutopilotAccessCheckResponse
|
||||
{
|
||||
accessPresent,
|
||||
isTransient,
|
||||
requiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
|
||||
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
|
||||
AccessPresent = accessPresent,
|
||||
IsTransient = isTransient,
|
||||
RequiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
|
||||
HomingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
+6
-5
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -82,12 +83,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var result = await _service.RunAsync(table, dryRun, maxRows, continuation);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new BackfillJobRunResponse
|
||||
{
|
||||
success = true,
|
||||
result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt = DateTime.UtcNow,
|
||||
Success = true,
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = DateTime.UtcNow,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
+10
-30
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -50,7 +51,7 @@ public class CustomsArchiveQueryFunction
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var filterTenantId = query["tenantId"];
|
||||
|
||||
var summaries = new Dictionary<string, RunSummary>(System.StringComparer.Ordinal);
|
||||
var summaries = new Dictionary<string, CustomsArchiveRunSummary>(System.StringComparer.Ordinal);
|
||||
|
||||
var source = string.IsNullOrEmpty(filterTenantId)
|
||||
? _archive.QueryAllAsync()
|
||||
@@ -61,7 +62,7 @@ public class CustomsArchiveQueryFunction
|
||||
var key = entry.PartitionKey;
|
||||
if (!summaries.TryGetValue(key, out var summary))
|
||||
{
|
||||
summary = new RunSummary
|
||||
summary = new CustomsArchiveRunSummary
|
||||
{
|
||||
PartitionKey = entry.PartitionKey,
|
||||
TenantId = entry.TenantId,
|
||||
@@ -91,7 +92,7 @@ public class CustomsArchiveQueryFunction
|
||||
.ToList();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, count = runs.Count, runs });
|
||||
await response.WriteAsJsonAsync(new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -108,10 +109,10 @@ public class CustomsArchiveQueryFunction
|
||||
string historyRowKey)
|
||||
{
|
||||
var normalizedTenantId = tenantId.ToLowerInvariant();
|
||||
var items = new List<EntrySummary>();
|
||||
var items = new List<CustomsArchiveEntrySummary>();
|
||||
await foreach (var entry in _archive.QueryByRunAsync(normalizedTenantId, historyRowKey))
|
||||
{
|
||||
items.Add(new EntrySummary
|
||||
items.Add(new CustomsArchiveEntrySummary
|
||||
{
|
||||
PartitionKey = entry.PartitionKey,
|
||||
RowKey = entry.RowKey,
|
||||
@@ -123,7 +124,7 @@ public class CustomsArchiveQueryFunction
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, count = items.Count, entries = items });
|
||||
await response.WriteAsJsonAsync(new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ public class CustomsArchiveQueryFunction
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, entry });
|
||||
await response.WriteAsJsonAsync(new CustomsArchiveEntryResponse { Success = true, Entry = entry });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -189,7 +190,7 @@ public class CustomsArchiveQueryFunction
|
||||
requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, archiveRowKey);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true });
|
||||
await response.WriteAsJsonAsync(new SuccessOnlyResponse { Success = true });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -229,7 +230,7 @@ public class CustomsArchiveQueryFunction
|
||||
requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, deleted);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, deleted });
|
||||
await response.WriteAsJsonAsync(new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -239,25 +240,4 @@ public class CustomsArchiveQueryFunction
|
||||
if (s.Length <= maxLength) return s;
|
||||
return s.Substring(0, maxLength) + "…";
|
||||
}
|
||||
|
||||
public class RunSummary
|
||||
{
|
||||
public string PartitionKey { get; set; } = string.Empty;
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string HistoryRowKey { get; set; } = string.Empty;
|
||||
public System.DateTime ArchivedAt { get; set; }
|
||||
public int GatherRulesCount { get; set; }
|
||||
public int AnalyzeRulesCount { get; set; }
|
||||
public int ImeLogPatternsCount { get; set; }
|
||||
}
|
||||
|
||||
public class EntrySummary
|
||||
{
|
||||
public string PartitionKey { get; set; } = string.Empty;
|
||||
public string RowKey { get; set; } = string.Empty;
|
||||
public string OriginalTable { get; set; } = string.Empty;
|
||||
public string OriginalRowKey { get; set; } = string.Empty;
|
||||
public System.DateTime ArchivedAt { get; set; }
|
||||
public string EntityJsonPreview { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -50,7 +51,7 @@ public class DelegatedAdminManagementFunction
|
||||
{
|
||||
var assignments = await _delegatedAdminService.GetAllAsync();
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { assignments });
|
||||
await response.WriteAsJsonAsync(new DelegatedAdminListResponse { Assignments = assignments });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ public class DelegatedAdminManagementFunction
|
||||
entry.Upn, entry.TenantId, entry.Role, currentUpn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new { assignment = entry });
|
||||
await response.WriteAsJsonAsync(new DelegatedAdminGrantResponse { Assignment = entry });
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var blocked = await _blockedDeviceService.GetBlockedDevicesAsync(tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, blocked });
|
||||
await response.WriteAsJsonAsync(new BlockedDeviceListResponse { Success = true, Blocked = blocked });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -126,14 +127,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
userIdentifier, isKill ? "issued KILL signal to" : "blocked", serialNumber, tenantId, durationHours, reason, blockedSessionId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new BlockDeviceResponse
|
||||
{
|
||||
success = true,
|
||||
message = isKill
|
||||
Success = true,
|
||||
Message = isKill
|
||||
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
|
||||
: $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
unblockAt = DateTime.UtcNow.AddHours(durationHours),
|
||||
action
|
||||
UnblockAt = DateTime.UtcNow.AddHours(durationHours),
|
||||
Action = action
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -180,7 +181,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
userIdentifier, serialNumber, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, message = $"Device {serialNumber} unblocked." });
|
||||
await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = true, Message = $"Device {serialNumber} unblocked." });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -49,17 +50,17 @@ public class EmailTemplatesFunction
|
||||
|
||||
var overrideEntry = await _templates.GetOverrideAsync(templateKind);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
await response.WriteAsJsonAsync(new EmailTemplateResponse
|
||||
{
|
||||
kind = EmailTemplateService.KindKey(templateKind),
|
||||
subject = EmailTemplateService.Subject(templateKind),
|
||||
isOverridden = overrideEntry is not null,
|
||||
html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
|
||||
builtInHtml = EmailTemplateService.BuiltInRaw(templateKind),
|
||||
updatedBy = overrideEntry?.UpdatedBy,
|
||||
updatedUtc = overrideEntry?.UpdatedUtc,
|
||||
placeholder = EmailTemplateService.DomainPlaceholder,
|
||||
maxLength = EmailTemplateService.MaxHtmlLength,
|
||||
Kind = EmailTemplateService.KindKey(templateKind),
|
||||
Subject = EmailTemplateService.Subject(templateKind),
|
||||
IsOverridden = overrideEntry is not null,
|
||||
Html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
|
||||
BuiltInHtml = EmailTemplateService.BuiltInRaw(templateKind),
|
||||
UpdatedBy = overrideEntry?.UpdatedBy,
|
||||
UpdatedUtc = overrideEntry?.UpdatedUtc,
|
||||
Placeholder = EmailTemplateService.DomainPlaceholder,
|
||||
MaxLength = EmailTemplateService.MaxHtmlLength,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -82,7 +83,7 @@ public class EmailTemplatesFunction
|
||||
var saved = await _templates.SaveOverrideAsync(templateKind, body!.Html!, upn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { kind = saved.Kind, isOverridden = true, updatedBy = saved.UpdatedBy, updatedUtc = saved.UpdatedUtc });
|
||||
await response.WriteAsJsonAsync(new EmailTemplateSaveResponse { Kind = saved.Kind, IsOverridden = true, UpdatedBy = saved.UpdatedBy, UpdatedUtc = saved.UpdatedUtc });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ public class EmailTemplatesFunction
|
||||
await _templates.DeleteOverrideAsync(templateKind, TenantHelper.GetUserIdentifier(req));
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { kind = EmailTemplateService.KindKey(templateKind), isOverridden = false });
|
||||
await response.WriteAsJsonAsync(new EmailTemplateResetResponse { Kind = EmailTemplateService.KindKey(templateKind), IsOverridden = false });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ public class EmailTemplatesFunction
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { sentTo = toEmail, domainName = tenantConfig.DomainName, draft = draft is not null });
|
||||
await response.WriteAsJsonAsync(new EmailTemplateTestSendResponse { SentTo = toEmail, DomainName = tenantConfig.DomainName, Draft = draft is not null });
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -42,21 +43,21 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes));
|
||||
|
||||
var users = active.Select(u => new
|
||||
var users = active.Select(u => new ActiveUserItem
|
||||
{
|
||||
tenantId = u.TenantId,
|
||||
upn = u.Upn,
|
||||
userRole = u.UserRole,
|
||||
lastSeen = u.LastSeen,
|
||||
secondsAgo = (int)Math.Max(0, (now - u.LastSeen).TotalSeconds)
|
||||
TenantId = u.TenantId,
|
||||
Upn = u.Upn,
|
||||
UserRole = u.UserRole,
|
||||
LastSeen = u.LastSeen,
|
||||
SecondsAgo = (int)Math.Max(0, (now - u.LastSeen).TotalSeconds)
|
||||
}).ToList();
|
||||
|
||||
return await req.OkAsync(new
|
||||
return await req.OkAsync(new GetActiveUsersResponse
|
||||
{
|
||||
success = true,
|
||||
windowMinutes,
|
||||
activeCount = users.Count,
|
||||
users
|
||||
Success = true,
|
||||
WindowMinutes = windowMinutes,
|
||||
ActiveCount = users.Count,
|
||||
Users = users
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -53,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
: await _blockedDeviceService.GetAllBlockedDevicesAsync();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, blocked });
|
||||
await response.WriteAsJsonAsync(new BlockedDeviceListResponse { Success = true, Blocked = blocked });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -65,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
if (parsed.PageSize == null)
|
||||
{
|
||||
var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, parsed.DateFrom, parsed.DateTo, filters);
|
||||
return await req.OkAsync(new { success = true, count = logs.Count, logs });
|
||||
return await req.OkAsync(new AuditLogListResponse { Success = true, Count = logs.Count, Logs = logs });
|
||||
}
|
||||
|
||||
string? azureToken = null;
|
||||
@@ -120,12 +121,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
extras: nextLinkExtras);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
return await req.OkAsync(new AuditLogListResponse
|
||||
{
|
||||
success = true,
|
||||
count = page.Items.Count,
|
||||
logs = page.Items,
|
||||
nextLink,
|
||||
Success = true,
|
||||
Count = page.Items.Count,
|
||||
Logs = page.Items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+7
-6
@@ -3,6 +3,7 @@ using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -81,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var logs = string.IsNullOrEmpty(filterTenantId)
|
||||
? await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo, filters)
|
||||
: await _maintenanceRepo.GetAuditLogsAsync(filterTenantId, parsed.DateFrom, parsed.DateTo, filters);
|
||||
return await req.OkAsync(new { success = true, count = logs.Count, logs });
|
||||
return await req.OkAsync(new AuditLogListResponse { Success = true, Count = logs.Count, Logs = logs });
|
||||
}
|
||||
|
||||
string? azureToken = null;
|
||||
@@ -133,12 +134,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
extras: nextLinkExtras);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
return await req.OkAsync(new AuditLogListResponse
|
||||
{
|
||||
success = true,
|
||||
count = page.Items.Count,
|
||||
logs = page.Items,
|
||||
nextLink,
|
||||
Success = true,
|
||||
Count = page.Items.Count,
|
||||
Logs = page.Items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -13,7 +14,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-tenant READ endpoint for PLATFORM-OPERATIONAL events (Consent/Maintenance/Security/Tenant/Agent/
|
||||
/// SLA). Supports optional category + dateFrom/dateTo filters and opt-in pagination. Authentication +
|
||||
/// SLA). Supports optional category + eventType/severity/minSeverity + dateFrom/dateTo filters (all
|
||||
/// server-side except the tenant drill) and opt-in pagination. Authentication +
|
||||
/// GlobalReadOrAdmin + TenantScoping.None enforced by PolicyEnforcementMiddleware — a Global Admin and the
|
||||
/// read-only Global Reader reach it, but a delegated ("MSP") admin does NOT: ops-events is the platform
|
||||
/// operator's view, not customer telemetry, so it stays GA/Reader-only (None ⇒ no scoped-route rescue for
|
||||
@@ -55,23 +57,38 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
return bad;
|
||||
}
|
||||
|
||||
// Optional exact-match field filters (eventType / severity / minSeverity). Unlike
|
||||
// the tenantId drill below these are pushed into the STORAGE query, so an operator
|
||||
// asking for one event type no longer pulls a whole category over the wire.
|
||||
var fieldFilters = OpsEventFilterRequest.Parse(query);
|
||||
if (fieldFilters.Error != null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = fieldFilters.Error });
|
||||
return bad;
|
||||
}
|
||||
var filters = fieldFilters.Filters;
|
||||
|
||||
// OpsEvents are partitioned by category, not tenant — tenantId filter is applied
|
||||
// post-fetch. Pages may report fewer items than pageSize when the filter is
|
||||
// narrow (Azure scan returns pageSize raw rows; we filter and emit what passes).
|
||||
// The field filters are appended AFTER the pre-existing discriminators so a token
|
||||
// minted before they existed still fingerprints identically when none are named.
|
||||
var extrasList = new List<KeyValuePair<string, string?>>();
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
extrasList.Add(new KeyValuePair<string, string?>("category", category));
|
||||
if (!string.IsNullOrEmpty(filterTenantId))
|
||||
extrasList.Add(new KeyValuePair<string, string?>("tenantId", filterTenantId));
|
||||
extrasList.AddRange(OpsEventFilterRequest.ToExtras(filters));
|
||||
var extras = extrasList.Count > 0 ? extrasList.ToArray() : null;
|
||||
|
||||
if (parsed.PageSize == null)
|
||||
{
|
||||
var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo);
|
||||
var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo, filters);
|
||||
// Drill correctness: apply the optional ?tenantId= GA/Reader drill in-memory (OpsEvents PK
|
||||
// = category, so the storage query can't). Not a tenant-isolation boundary — see class doc.
|
||||
var filtered = FilterByTenant(events, filterTenantId).ToList();
|
||||
return await req.OkAsync(new { success = true, count = filtered.Count, events = filtered });
|
||||
return await req.OkAsync(new OpsEventListResponse { Success = true, Count = filtered.Count, Events = filtered });
|
||||
}
|
||||
|
||||
string? azureToken = null;
|
||||
@@ -99,7 +116,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
}
|
||||
|
||||
var page = await _repository.GetOpsEventsPageAsync(
|
||||
category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
|
||||
category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, filters);
|
||||
|
||||
// Drill correctness: same optional ?tenantId= GA/Reader drill on the paged path — see FilterByTenant.
|
||||
var pageItems = FilterByTenant(page.Items, filterTenantId).ToList();
|
||||
@@ -123,12 +140,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
extras: extras);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
return await req.OkAsync(new OpsEventListResponse
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
events = pageItems,
|
||||
nextLink,
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Events = pageItems,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user