mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba67162aa | |||
| efc96258d6 | |||
| 9d8b0fc254 | |||
| f3028041af | |||
| 3628c7bc79 | |||
| 395fdd49df | |||
| 5edb64f3c9 | |||
| 46965af290 | |||
| 1bfdb68ff6 | |||
| 5ee5114d5c | |||
| a7bb7ebf15 | |||
| a7a1cc43c1 | |||
| 2a5ad65dd7 | |||
| 960b3d8ed9 | |||
| 86bb255b11 | |||
| d3a10dfb96 | |||
| 80d0b30cba | |||
| aee723e497 | |||
| 427c923530 | |||
| b46a8f1571 | |||
| 6e294c010e | |||
| dc54c9555c | |||
| 784fd99240 | |||
| 607d6615b0 | |||
| 8d4f7ea0f9 | |||
| 0e2d620ad4 | |||
| 69d61c6c26 | |||
| cfb28d83b6 | |||
| f795fbcb4e | |||
| d9d22bc443 | |||
| 6bd5728652 | |||
| d6affae8fc | |||
| a70c5de667 | |||
| e62bac9fea | |||
| 1e94a7aa91 | |||
| c330e5c91a | |||
| 6015548172 | |||
| 3f661d097d |
@@ -82,7 +82,7 @@ jobs:
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 141
|
||||
run: npm run lint -- --max-warnings 115
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -39,6 +39,10 @@ jobs:
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
# and the default app for browsers without a stored selection (legacy|primary).
|
||||
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
|
||||
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
|
||||
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](LICENSE)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
|
||||
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
|
||||
|
||||
## Private Preview
|
||||
## Availability
|
||||
|
||||
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
|
||||
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/SessionList.png" width="45%" />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: concept
|
||||
title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Activation
|
||||
|
||||
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
|
||||
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
|
||||
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
|
||||
30 s negative cache).
|
||||
|
||||
Two activation routes, one shared implementation
|
||||
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
|
||||
|
||||
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
|
||||
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
|
||||
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
|
||||
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
|
||||
Typical latency: ~1 minute after signup.
|
||||
|
||||
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
|
||||
arbitrates concurrent activations; exactly one caller runs the side effects
|
||||
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
|
||||
|
||||
## The welcome-mail race
|
||||
|
||||
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
|
||||
same table) — the only reliable address: it is typed by the user on the activation-pending
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
**Arbitration — write-then-read on both sides plus a send-once marker:**
|
||||
|
||||
* The approval path writes the `approved` row, then reads the address.
|
||||
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
|
||||
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
|
||||
activation for exactly this window).
|
||||
* Since both do write-then-read, at least one sees both halves and calls
|
||||
`TenantApprovalService.TrySendWelcomeEmailAsync`.
|
||||
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
|
||||
sends: at most one wins. The marker is consumed strictly **after** the address check —
|
||||
consuming it without an address would permanently suppress the mail.
|
||||
|
||||
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
|
||||
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
|
||||
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
notification-email save path"; user saves address at T+69 s → save path sees approved
|
||||
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
|
||||
* Address saved before manual approve (classic shape): approval path finds the address,
|
||||
wins the marker, sends; a later re-save of the same address does not re-send.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -37,6 +37,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
|
||||
# MCP Server
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
|
||||
|
||||
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
|
||||
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
|
||||
+21
@@ -92,6 +92,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
|
||||
}
|
||||
|
||||
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
|
||||
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
|
||||
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
|
||||
{
|
||||
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
|
||||
// lines with an explicit bias. Without bias handling the line either failed to
|
||||
// match at all (old regex) or would be misread in agent-local time.
|
||||
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
|
||||
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
|
||||
}
|
||||
|
||||
// ── Oversized line → parses, no throw, no truncation of the message ───
|
||||
|
||||
[Fact]
|
||||
|
||||
+28
-5
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
// Session df1fcf47: the DO collector observed the completion live, but the event
|
||||
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
|
||||
// the past — which dragged Sessions.StartedAt back and inflated the duration.
|
||||
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
|
||||
// last matched line.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do-poll", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
|
||||
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
// Not a replay — live observation must never trip the replay suppression,
|
||||
// even with a stale unrelated line on the tracker.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
int priorDoCalls = 0;
|
||||
int priorScriptCalls = 0;
|
||||
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
|
||||
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
|
||||
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.OnImeAgentVersion("1.0.0.0");
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
|
||||
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
|
||||
|
||||
Assert.Equal(1, priorVersionCalls);
|
||||
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
Action<string> priorVersion = _ => { };
|
||||
Action<AppPackageState> priorDo = _ => { };
|
||||
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
|
||||
Action<ScriptExecutionState> priorScript = _ => { };
|
||||
f.Tracker.OnImeAgentVersion = priorVersion;
|
||||
f.Tracker.OnDoTelemetryReceived = priorDo;
|
||||
|
||||
+25
-5
@@ -22,9 +22,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format
|
||||
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
|
||||
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
|
||||
// UTC = local + bias) — without the optional bias group such lines would not match
|
||||
// at all and their content would be invisible to every pattern.
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
);
|
||||
|
||||
@@ -45,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var biasStr = match.Groups["bias"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
var component = match.Groups["component"].Value;
|
||||
var typeStr = match.Groups["type"].Value;
|
||||
@@ -52,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
|
||||
DateTime timestamp;
|
||||
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
|
||||
if (!TryParseTimestamp(dateStr, timeStr, biasStr, out timestamp))
|
||||
{
|
||||
timestamp = DateTime.UtcNow;
|
||||
}
|
||||
@@ -72,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, string biasStr, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
|
||||
@@ -116,7 +120,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"M-d-yyyy HH:mm:ss"
|
||||
};
|
||||
|
||||
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
|
||||
// Bias-suffixed time ("+480"): the writer declared its own UTC offset in minutes
|
||||
// (GetTimeZoneInformation convention: UTC = written time + bias). Honor it instead
|
||||
// of assuming the agent's local timezone — writer and agent can disagree (mixed
|
||||
// components, mid-enrollment timezone change; session df1fcf47).
|
||||
if (!string.IsNullOrEmpty(biasStr)
|
||||
&& int.TryParse(biasStr, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var biasMinutes))
|
||||
{
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out result))
|
||||
{
|
||||
result = DateTime.SpecifyKind(result.AddMinutes(biasMinutes), DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// No bias: CMTrace timestamps are in LOCAL time. Parse as local, then convert to UTC.
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal, out result))
|
||||
{
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
getPackageStates: () => imeHost.PackageStates,
|
||||
onDoTelemetryReceived: pkg =>
|
||||
{
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
// null source timestamp: poll-driven completion, no log line backs it —
|
||||
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
|
||||
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitImeAgentVersion(version);
|
||||
}
|
||||
|
||||
private void OnDoTelemetryReceived(AppPackageState app)
|
||||
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnDoTelemetryReceived?.Invoke(app);
|
||||
EmitDoTelemetry(app);
|
||||
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitAppState(app, oldState, newState);
|
||||
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
|
||||
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app)
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id)) return;
|
||||
|
||||
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
// Two trigger paths with different time grounding (session df1fcf47: a single
|
||||
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
|
||||
// session StartedAt back 7h and inflated the reported duration):
|
||||
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
|
||||
// event time — resolve/clamp it and apply historic-replay suppression.
|
||||
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
|
||||
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
|
||||
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
|
||||
DateTime now;
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
if (sourceTimestampUtc.HasValue)
|
||||
{
|
||||
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
now = _clock.UtcNow;
|
||||
derivedFromClock = true;
|
||||
rawSourceTs = null;
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + round-trip tests for <see cref="AdminConfiguration.AutoApproveNewTenants"/>.
|
||||
/// The flag gates the tenant auto-approve queue worker — defaulting to false keeps new tenant
|
||||
/// signups on manual Global Admin approval until the operator deliberately opts in.
|
||||
/// </summary>
|
||||
public class AdminConfigurationAutoApproveNewTenantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
// CreateDefault() is called when no admin-config row exists yet — if it somehow
|
||||
// enabled auto-approve, tenants would activate without any operator opt-in.
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { AutoApproveNewTenants = true };
|
||||
Assert.True(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,7 @@ public class AdminConfigurationRateLimitSyncTests
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + fail-closed tests for <see cref="AdminConfiguration.SelfServiceAppHomingEnabled"/>.
|
||||
/// The flag is the kill switch of the dual app-reg self-service homing flip — defaulting to false
|
||||
/// keeps the consent funnel and auto-flip off until the operator deliberately opts in, and a
|
||||
/// storage error must read as "disabled" (kill switch wins over a blip).
|
||||
/// </summary>
|
||||
public class AdminConfigurationSelfServiceAppHomingTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { SelfServiceAppHomingEnabled = true };
|
||||
Assert.True(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_returns_true_when_flag_enabled()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { SelfServiceAppHomingEnabled = true });
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.True(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_fails_closed_on_repository_error()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.False(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization/validation matrix for <see cref="AppHomingService.EvaluateManualFlip"/> — the
|
||||
/// pure decision behind <c>POST config/{tenantId}/app-homing</c>. Contract: tenant admins may
|
||||
/// only flip TO primary, without force, with the self-service flag on, and only after a
|
||||
/// successful consent probe; Global Admins may flip both directions and force past the probe.
|
||||
/// </summary>
|
||||
public class AppHomingDecisionTests
|
||||
{
|
||||
private static readonly AppHomingProbeResult ProbeOk = new(Succeeded: true, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeFail = new(Succeeded: false, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeTransient = new(Succeeded: false, IsTransient: true);
|
||||
|
||||
private static AppHomingDecision Evaluate(
|
||||
bool isGlobalAdmin = false,
|
||||
bool selfServiceEnabled = true,
|
||||
bool legacyConfigured = true,
|
||||
bool currentlyPrimary = false,
|
||||
bool targetPrimary = true,
|
||||
bool force = false,
|
||||
AppHomingProbeResult? probe = null)
|
||||
=> AppHomingService.EvaluateManualFlip(
|
||||
isGlobalAdmin, selfServiceEnabled, legacyConfigured, currentlyPrimary, targetPrimary, force, probe);
|
||||
|
||||
[Fact]
|
||||
public void ParallelWindowInactive_denies_for_everyone()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, legacyConfigured: false, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("parallel-window-inactive", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)] // legacy-homed, target legacy
|
||||
[InlineData(true, true)] // primary-homed, target primary
|
||||
public void AlreadyAtTarget_is_a_noop(bool currentlyPrimary, bool targetPrimary)
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: currentlyPrimary, targetPrimary: targetPrimary);
|
||||
Assert.Equal(AppHomingDecisionKind.AllowNoOp, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_revert_to_legacy_is_denied()
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("revert-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_force_is_denied()
|
||||
{
|
||||
var decision = Evaluate(force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("force-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_with_flag_off_is_denied()
|
||||
{
|
||||
// The kill switch: turning the admin-config flag off must stop tenant-admin flips
|
||||
// immediately, while the GA path below stays open (operator lever survives).
|
||||
var decision = Evaluate(selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("self-service-disabled", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_with_flag_off_still_requires_only_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_without_probe_requires_probe()
|
||||
{
|
||||
var decision = Evaluate();
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_successful_probe_is_allowed()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeOk);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_failed_probe_is_denied()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeFail);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-failed", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_transient_probe_is_denied_as_retryable()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeTransient);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-transient", decision.ReasonCode);
|
||||
Assert.Equal(503, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_flip_without_force_also_requires_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_force_skips_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_revert_to_legacy_needs_no_probe()
|
||||
{
|
||||
// The legacy app is consented by construction — probing it would be meaningless.
|
||||
var decision = Evaluate(isGlobalAdmin: true, currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
|
||||
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
|
||||
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
|
||||
/// </summary>
|
||||
public class AppHomingServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
private const string Actor = "admin@contoso.com";
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<GraphTokenService> _graphTokenMock;
|
||||
private readonly Mock<IGraphFeatureDetector> _detectorMock;
|
||||
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
|
||||
private readonly List<OpsEventEntry> _savedOpsEvents = new();
|
||||
private readonly EntraAppRegistry _registry;
|
||||
private readonly AppHomingService _sut;
|
||||
|
||||
public AppHomingServiceTests() : this(legacyConfigured: true) { }
|
||||
|
||||
private AppHomingServiceTests(bool legacyConfigured)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
};
|
||||
if (legacyConfigured)
|
||||
{
|
||||
configValues["EntraId:LegacyClientId"] = LegacyId;
|
||||
configValues["EntraId:LegacyClientSecret"] = "legacy-secret";
|
||||
}
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
|
||||
_registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_adminConfigMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_graphTokenMock = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
configuration,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
_detectorMock = new Mock<IGraphFeatureDetector>();
|
||||
_maintenanceMock = new Mock<IMaintenanceRepository>();
|
||||
_maintenanceMock
|
||||
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new AppHomingService(
|
||||
NullLogger<AppHomingService>.Instance,
|
||||
_adminConfigMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_registry,
|
||||
_graphTokenMock.Object,
|
||||
_detectorMock.Object,
|
||||
_maintenanceMock.Object,
|
||||
opsService,
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
|
||||
|
||||
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
|
||||
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
|
||||
var config = LegacyHomedConfig();
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
SetupProbe(GraphTokenResult.Success("tok"));
|
||||
}
|
||||
|
||||
private static TenantConfiguration LegacyHomedConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.HomedAppClientId = null;
|
||||
return config;
|
||||
}
|
||||
|
||||
private void SetupProbe(GraphTokenResult result) =>
|
||||
_graphTokenMock
|
||||
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_eligible_for_legacy_homed_tenant_with_flag_on()
|
||||
{
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_legacy_pair_unconfigured()
|
||||
{
|
||||
var sut = new AppHomingServiceTests(legacyConfigured: false)._sut;
|
||||
Assert.False(await sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_for_primary_homed_tenant()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_does_not_block_on_entra_app_roles()
|
||||
{
|
||||
// Deliberate: app-role tenants may flip (operator informs them personally); the
|
||||
// reminder trail is asserted in the flip test below.
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
|
||||
{
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
|
||||
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
|
||||
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
|
||||
d != null && d.ContainsKey("HomedAppClientId"))), Times.Once);
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.PermanentFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_transient_probe()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.TransientFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_for_primary_homed_tenant_and_never_probes()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(config, Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(It.IsAny<string>(),
|
||||
It.IsAny<EntraAppCredentials>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── FlipAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_of_entra_app_roles_tenant_emits_reminder_ops_event()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_noops_when_already_at_target()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
|
||||
{
|
||||
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
|
||||
// the fresh read returning null is a hard stop.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_back_to_legacy_persists_null_homing()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
|
||||
|
||||
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -44,7 +45,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
{ CallBase = false };
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
@@ -76,6 +77,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
@@ -87,11 +89,15 @@ public class AuthFunctionSideEffectTests
|
||||
previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
mcpUserMock.Object);
|
||||
mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
@@ -126,12 +132,89 @@ public class AuthFunctionSideEffectTests
|
||||
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
|
||||
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
|
||||
// Signup enqueues the delayed auto-approve unconditionally — the worker is the
|
||||
// decision point (flag check at processing time).
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.Is<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
|
||||
e => e.TenantId == TenantId && e.SignupUpn == Upn),
|
||||
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
// incident 2026-07-31: the login right after an app-homing flip carries the
|
||||
// changed audience; blind-saving the stale cached entity reverted the flip.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip()
|
||||
{
|
||||
// Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
cached.HomedAppClientId = null;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = LegacyAppId;
|
||||
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
|
||||
Assert.NotNull(fresh.LastAuthClientIdSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite()
|
||||
{
|
||||
// Another instance already recorded the new client id — the fresh re-check must
|
||||
// suppress the redundant write even though the cached view still looks outdated.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead()
|
||||
{
|
||||
// Steady state (same app as last time) must stay a zero-I/O no-op.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,7 +239,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -168,7 +251,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -179,7 +262,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -196,7 +279,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// SaveConfig should still have been called before the fire-and-forget
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -217,7 +300,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Null(config.DisabledReason);
|
||||
Assert.Null(config.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,7 +313,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -241,7 +324,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -254,7 +337,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -223,11 +223,11 @@ public class AuthFunctionTests
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate 2: Preview Gate
|
||||
// Gate 2: Activation Gate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PreviewGate_NotApproved_Returns403PrivatePreview()
|
||||
public void PreviewGate_NotApproved_Returns403PendingActivation()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
|
||||
@@ -238,7 +238,7 @@ public class AuthFunctionTests
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("PrivatePreview", (string)body.error);
|
||||
Assert.Equal("PendingActivation", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+32
@@ -203,6 +203,38 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
|
||||
}
|
||||
|
||||
// ── Dual app-reg window (C4A8 move): primary=new app, legacy=old app via LegacyClientId ──
|
||||
// The middleware folds EntraId:LegacyClientId into the additional-id set via
|
||||
// CombineAdditionalClientIdSources, so both apps' tokens validate in bare AND api:// form.
|
||||
|
||||
private const string NewAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string OldAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
[Theory]
|
||||
[InlineData(NewAppId)]
|
||||
[InlineData("api://" + NewAppId)]
|
||||
[InlineData(OldAppId)]
|
||||
[InlineData("api://" + OldAppId)]
|
||||
public void DualAppWindow_accepts_both_apps_tokens_in_both_audience_forms(string audience)
|
||||
{
|
||||
var clientIds = AuthenticationMiddleware.ResolveConfiguredClientIds(
|
||||
NewAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, null),
|
||||
out _);
|
||||
|
||||
Assert.NotNull(ValidateWith(Mint(RsaCreds, audience: audience), clientIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombineAdditionalClientIdSources_joins_and_skips_empty_sources()
|
||||
{
|
||||
Assert.Equal($"{OldAppId},{SecondaryClientId}",
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, SecondaryClientId));
|
||||
Assert.Equal(OldAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources($" {OldAppId} ", null, ""));
|
||||
Assert.Null(AuthenticationMiddleware.CombineAdditionalClientIdSources(null, "", " "));
|
||||
}
|
||||
|
||||
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The universal pre-write snapshot hook in <see cref="TableConfigRepository"/>:
|
||||
/// every overwriting save of a TenantConfiguration/AdminConfiguration row must first
|
||||
/// snapshot the stored row into ConfigurationBackups — except row creations and
|
||||
/// noise-only changes — and a backup-storage failure must never break the save (fail-soft).
|
||||
/// </summary>
|
||||
public class ConfigBackupHookTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
|
||||
public Harness(TableEntity? stored)
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
}
|
||||
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
Backup.Object,
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantConfiguration StoredConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors production: every real writer round-trips the STORED row (read-modify-write),
|
||||
/// so time-stamped fields like OnboardedAt match the stored values exactly. Constructing
|
||||
/// a second CreateDefault instead would fabricate an OnboardedAt diff that cannot occur.
|
||||
/// </summary>
|
||||
private static TenantConfiguration RoundtripClone(TenantConfiguration stored)
|
||||
=> TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
[Fact]
|
||||
public async Task RealChange_SnapshotsStoredRow_ThenSaves_ThenPrunes()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
incoming.UpdatedBy = "ga@operator.example";
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config row itself
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal(TenantId, snapshot!.PartitionKey);
|
||||
Assert.Equal("portal-put", snapshot.Source);
|
||||
Assert.Equal("test change", snapshot.Reason);
|
||||
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
|
||||
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
|
||||
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
|
||||
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
|
||||
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_NoExistingRow_NoSnapshot()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
// The auth-side-effect write: only LastAuthClientId/Since + bookkeeping move.
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
incoming.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
incoming.UpdatedBy = "System (auth)";
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
|
||||
|
||||
Assert.True(saved); // the save itself still goes through
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOpSave_IdenticalContent_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupStorageFailure_IsFailSoft_SaveStillSucceeds()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "backup table throttled"));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config write went through regardless
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
await harness.Sut.SaveTenantConfigurationAsync(incoming);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("unknown", snapshot!.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminConfigSave_SnapshotsUnderGlobalConfigPartition()
|
||||
{
|
||||
var stored = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
};
|
||||
var harness = new Harness(TableConfigRepository.ConvertToAdminTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
|
||||
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
|
||||
Assert.Equal("admin-config", snapshot.Source);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync("GlobalConfig", Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"),
|
||||
/// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics.
|
||||
/// </summary>
|
||||
public class ConfigBackupRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigBackupRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
public List<(string Pk, string Rk)> Deletes { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.DeleteEntityAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
|
||||
{
|
||||
Deletes.Add((pk, rk));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
Sut = new TableConfigBackupRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
NullLogger<TableConfigBackupRepository>.Instance);
|
||||
}
|
||||
|
||||
public void SetupQuery(params TableEntity[] rows)
|
||||
{
|
||||
var page = Page<TableEntity>.FromValues(rows, continuationToken: null, Mock.Of<Response>());
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
|
||||
}
|
||||
}
|
||||
|
||||
private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new()
|
||||
{
|
||||
PartitionKey = TenantId,
|
||||
RowKey = rowKey,
|
||||
TenantId = TenantId,
|
||||
EntityJson = "{\"DomainName\":\"contoso.com\"}",
|
||||
ChangedBy = "ga@operator.example",
|
||||
Source = "mcp-patch",
|
||||
Reason = "retention bump",
|
||||
DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}",
|
||||
BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_StoreAndMap_PreservesEveryField()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var entry = Entry();
|
||||
|
||||
await harness.Sut.UpsertAsync(entry);
|
||||
var stored = Assert.Single(harness.Upserts);
|
||||
|
||||
// Feed the stored entity back through the read path (TryGetAsync → Map).
|
||||
harness.Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey);
|
||||
|
||||
Assert.NotNull(mapped);
|
||||
Assert.Equal(entry.PartitionKey, mapped!.PartitionKey);
|
||||
Assert.Equal(entry.RowKey, mapped.RowKey);
|
||||
Assert.Equal(entry.TenantId, mapped.TenantId);
|
||||
Assert.Equal(entry.EntityJson, mapped.EntityJson);
|
||||
Assert.Equal(entry.ChangedBy, mapped.ChangedBy);
|
||||
Assert.Equal(entry.Source, mapped.Source);
|
||||
Assert.Equal(entry.Reason, mapped.Reason);
|
||||
Assert.Equal(entry.DiffJson, mapped.DiffJson);
|
||||
Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_LaterTimestamps_SortFirst()
|
||||
{
|
||||
var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc));
|
||||
var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
// Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so
|
||||
// Azure's RowKey-ascending partition scan returns newest-first.
|
||||
Assert.True(string.CompareOrdinal(later, earlier) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_SameTick_DistinctViaGuidSuffix()
|
||||
{
|
||||
var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
|
||||
Assert.NotEqual(
|
||||
TableConfigBackupRepository.BuildRowKey(at),
|
||||
TableConfigBackupRepository.BuildRowKey(at));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder()
|
||||
{
|
||||
var harness = new Harness();
|
||||
// Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must
|
||||
// delete rows 3 and 4, never the two newest.
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"),
|
||||
new TableEntity(TenantId, "0003_old"),
|
||||
new TableEntity(TenantId, "0004_oldest"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_WithinKeep_DeletesNothing()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(0, deleted);
|
||||
Assert.Empty(harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByPartition_CapsAtMax()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001"),
|
||||
new TableEntity(TenantId, "0002"),
|
||||
new TableEntity(TenantId, "0003"));
|
||||
|
||||
var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2);
|
||||
|
||||
Assert.Equal(2, listed.Count);
|
||||
Assert.Equal("0001", listed[0].RowKey);
|
||||
}
|
||||
}
|
||||
@@ -192,4 +192,37 @@ public class ConfigRedactionTests
|
||||
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
|
||||
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
|
||||
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
|
||||
|
||||
// ── ?view=redacted opt-in (MCP get_tenant_config): forces redaction even for GA ──
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_OwnTenantAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsTenantAdmin = true, TenantId = TenantA, TargetTenantId = TenantA },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_NoParam_False()
|
||||
// Without the opt-in the GA clearance stands — the portal Settings UI depends on it.
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
""));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_OtherViewValue_False()
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_ReaderWithoutClearance_AlwaysTrue_RegardlessOfParam()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalReader = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(16, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins)
|
||||
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DelegatedAdminEditionGateTests
|
||||
return new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ public class DelegatedAdminEditionGateTests
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class DelegatedAdminServiceTests
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution matrix for the dual app-registration window (see EntraAppRegistry):
|
||||
/// legacy unconfigured ⇒ always primary (code deploy is a no-op before the config swap);
|
||||
/// null homing ⇒ legacy; known ids ⇒ that app; unknown ⇒ primary fallback.
|
||||
/// </summary>
|
||||
public class EntraAppRegistryTests
|
||||
{
|
||||
private const string PrimaryId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string LegacyId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
private static EntraAppRegistry Build(Dictionary<string, string?> settings) =>
|
||||
new(new ConfigurationBuilder().AddInMemoryCollection(settings).Build(),
|
||||
NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static EntraAppRegistry DualApp() => Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = LegacyId,
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
private static TenantConfiguration Tenant(string? homedAppClientId) => new()
|
||||
{
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
HomedAppClientId = homedAppClientId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LegacyUnconfigured_AlwaysResolvesPrimary_EvenForNullHoming()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(Tenant(null)).ClientId);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(null).ClientId);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(null));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal(LegacyId, resolved.ClientId);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingConfigRow_ResolvesLegacy_SameAsNullHoming()
|
||||
{
|
||||
Assert.True(DualApp().ResolveForTenant(null).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrimaryHoming_ResolvesPrimary_CaseAndBraceInsensitive()
|
||||
{
|
||||
var registry = DualApp();
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId)).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId.ToUpperInvariant())).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant("{" + PrimaryId + "}")).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(LegacyId));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownHoming_FallsBackToPrimary()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant("99999999-9999-9999-9999-999999999999"));
|
||||
Assert.False(resolved.IsLegacy);
|
||||
Assert.Equal(PrimaryId, resolved.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedLegacyClientId_TreatedAsUnconfigured()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:LegacyClientId"] = "not-a-guid",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PrimaryId, true)]
|
||||
[InlineData("api://" + PrimaryId, true)]
|
||||
[InlineData("API://" + PrimaryId, true)]
|
||||
[InlineData(LegacyId, false)]
|
||||
[InlineData("api://" + LegacyId, false)]
|
||||
[InlineData("https://graph.microsoft.com", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void IsPrimary_MatchesBareAndApiPrefixedAudiences(string? audience, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DualApp().IsPrimary(audience));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api://" + LegacyId, LegacyId)]
|
||||
[InlineData(LegacyId, LegacyId)]
|
||||
[InlineData("API://886AB5E2-6144-442C-80CC-9B28E0667731", PrimaryId)]
|
||||
[InlineData("https://graph.microsoft.com", null)] // non-GUID audience must never persist as provenance
|
||||
[InlineData("api://not-a-guid", null)]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(" ", null)]
|
||||
public void NormalizeAudience_StripsApiPrefixAndRequiresGuid(string? audience, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, EntraAppRegistry.NormalizeAudience(audience));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the edition-resolution matrix and the per-edition entitlement values of
|
||||
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
|
||||
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
|
||||
/// null/empty and unknown values all resolve to Community without any data migration.
|
||||
/// "pro" / the legacy stored value "enterprise" (or an active trial) yields Pro — the legacy
|
||||
/// stored tier "free", null/empty and unknown values all resolve to Community without any
|
||||
/// data migration.
|
||||
/// </summary>
|
||||
public class FeatureEntitlementCatalogTests
|
||||
{
|
||||
@@ -16,31 +17,34 @@ public class FeatureEntitlementCatalogTests
|
||||
// ── ResolveEdition matrix ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("enterprise")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("Pro")]
|
||||
[InlineData("PRO")]
|
||||
[InlineData(" pro ")]
|
||||
[InlineData("enterprise")] // legacy stored value — must stay readable as Pro
|
||||
[InlineData("Enterprise")]
|
||||
[InlineData("ENTERPRISE")]
|
||||
[InlineData(" enterprise ")]
|
||||
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
|
||||
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("community")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("premium")] // unknown value → fail-closed
|
||||
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
|
||||
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
|
||||
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
|
||||
}
|
||||
|
||||
@@ -59,11 +63,28 @@ public class FeatureEntitlementCatalogTests
|
||||
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")]
|
||||
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
// ── IsPermanentProTier ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", true)]
|
||||
[InlineData(" PRO ", true)]
|
||||
[InlineData("enterprise", true)] // legacy stored value
|
||||
[InlineData("free", false)]
|
||||
[InlineData("community", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPermanentProTier_Matrix(string? tier, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FeatureEntitlementCatalog.IsPermanentProTier(tier));
|
||||
}
|
||||
|
||||
// ── Entitlement values ───────────────────────────────────────────────────────
|
||||
@@ -76,20 +97,24 @@ public class FeatureEntitlementCatalogTests
|
||||
Assert.Null(e.UserRateLimitPerMinute);
|
||||
Assert.Null(e.DeviceRateLimitPerMinute);
|
||||
Assert.False(e.DelegatedAdminAllowed);
|
||||
Assert.False(e.BootstrapIncluded);
|
||||
Assert.False(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("community", e.McpUsagePlanName);
|
||||
Assert.Equal(100, e.McpDailyRequestLimit);
|
||||
Assert.Equal(3000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enterprise_Entitlements_MatchMatrix()
|
||||
public void Pro_Entitlements_MatchMatrix()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
|
||||
Assert.Equal(365, e.RetentionCapDays);
|
||||
Assert.Equal(150, e.UserRateLimitPerMinute);
|
||||
Assert.Equal(150, e.DeviceRateLimitPerMinute);
|
||||
Assert.True(e.DelegatedAdminAllowed);
|
||||
Assert.Equal("enterprise", e.McpUsagePlanName);
|
||||
Assert.True(e.BootstrapIncluded);
|
||||
Assert.True(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("pro", e.McpUsagePlanName);
|
||||
Assert.Equal(1000, e.McpDailyRequestLimit);
|
||||
Assert.Equal(20000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// — and that forces a deliberate code review of whether the new field is non-sensitive.
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
|
||||
// self-service migration banner).
|
||||
"appHomingFunnelActive",
|
||||
"bootstrapTokenEnabled",
|
||||
// Non-sensitive by review: bool only ("is a contact address set"), never the
|
||||
// address itself (admin-gated full config only).
|
||||
"contactEmailSet",
|
||||
"diagnosticsUploadConfigured",
|
||||
"edition",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
@@ -54,11 +60,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
|
||||
BootstrapTokenEnabled = true,
|
||||
ValidateAutopilotDevice = true,
|
||||
ShowScriptOutput = false,
|
||||
EnableSoftwareInventoryAnalyzer = true,
|
||||
EnableIntegrityBypassAnalyzer = false,
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
};
|
||||
|
||||
@@ -72,6 +80,55 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
// ── Effective feature gates (plan-derived) ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
|
||||
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
|
||||
{
|
||||
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
|
||||
// effective value must be false.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "free",
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro",
|
||||
UnrestrictedModeEnabled = false,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
|
||||
{
|
||||
@@ -159,12 +216,14 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_EnterpriseTier_NotTrial()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
|
||||
public void Payload_PermanentProTier_NotTrial(string storedTier)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
|
||||
@@ -178,7 +237,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var expiry = Now.AddDays(10);
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.True(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
|
||||
@@ -193,4 +252,27 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
|
||||
}
|
||||
|
||||
// ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("it-ops@contoso.com", true)]
|
||||
public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { ContactEmail = stored });
|
||||
|
||||
Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-28
@@ -327,17 +327,23 @@ public class GraphFeatureDetectorTests
|
||||
var handler = new SwappableTokenHandler(
|
||||
BuildJwt(Array.Empty<Claim>(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tokenConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var tokenService = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build());
|
||||
tokenConfig,
|
||||
new EntraAppRegistry(tokenConfig, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
|
||||
var detector = new GraphFeatureDetector(
|
||||
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
|
||||
@@ -454,6 +460,28 @@ public class GraphFeatureDetectorTests
|
||||
|
||||
// ── Test seam: token service stub + detector factory ──────────────────────────────
|
||||
|
||||
/// <summary>Minimal EntraId config for the GraphTokenService base ctor (no legacy app).</summary>
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration StubEntraConfig() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Dual app-reg dependencies for the GraphTokenService base ctor. No legacy app configured
|
||||
/// ⇒ the registry always resolves primary and the tenant-config lookup is never consulted.
|
||||
/// </summary>
|
||||
private static EntraAppRegistry StubAppRegistry() =>
|
||||
new(StubEntraConfig(), NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static AutopilotMonitor.Functions.Services.TenantConfigurationService StubTenantConfigService() =>
|
||||
new(Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// Subclass that bypasses the real AAD HTTP call so tests can drive the detector with
|
||||
/// canned <see cref="GraphTokenResult"/> values. Base ctor dependencies are satisfied
|
||||
@@ -469,13 +497,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -506,13 +530,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_firstToken = firstToken;
|
||||
_laterToken = laterToken;
|
||||
@@ -549,13 +569,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_onInvalidate = onInvalidate;
|
||||
}
|
||||
|
||||
@@ -33,15 +33,28 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the service with the dual app-reg dependencies. No legacy app is configured in
|
||||
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
|
||||
/// lookup is never consulted for the decision — a null-returning repo is fine.
|
||||
/// </summary>
|
||||
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
|
||||
new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
|
||||
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
|
||||
|
||||
@@ -148,11 +161,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
// R1 starts; its POST parks in flight (holding the gate, before writing to the cache).
|
||||
var inflight = svc.GetAccessTokenAsync(TenantA);
|
||||
@@ -188,11 +197,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => svc.GetAccessTokenAsync(TenantA)).ToArray();
|
||||
|
||||
@@ -209,6 +214,54 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, handler.Entered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenForAppAsync_caches_under_the_supplied_app_without_cross_app_poisoning()
|
||||
{
|
||||
// Dual app-reg window: the homing-flip consent probe mints under EXPLICIT primary
|
||||
// credentials for a still-legacy-homed tenant. Contract: the probe token caches under the
|
||||
// primary key, a second probe is served from cache, and the tenant's normal (legacy-
|
||||
// resolved) path still mints its own token — never the probe's.
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, "{\"access_token\":\"tok\",\"expires_in\":3599}");
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = "bbbbbbbb-0000-0000-0000-000000000002",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
})
|
||||
.Build();
|
||||
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
registry,
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal("tok", probe1.AccessToken);
|
||||
Assert.Equal("tok", probe2.AccessToken);
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA)); // second probe served from the primary-keyed cache
|
||||
|
||||
// Normal path resolves LEGACY for this tenant (no homing stored) — must be a cache miss
|
||||
// against the probe's primary-keyed entry and mint its own token.
|
||||
var homed = await svc.GetAccessTokenAsync(TenantA);
|
||||
Assert.Equal("tok", homed.AccessToken);
|
||||
Assert.Equal(2, TokenPosts(handler, TenantA));
|
||||
|
||||
// InvalidateTenant drops BOTH apps' entries — the next probe re-mints fresh.
|
||||
svc.InvalidateTenant(TenantA);
|
||||
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal(3, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -22,9 +23,11 @@ public class HealthCheckServicePoisonQueueTests
|
||||
[Fact]
|
||||
public async Task Check_AllPoisonQueuesEmpty_ReportsHealthy()
|
||||
{
|
||||
// Existing-but-drained poison queues: enumerated, probed, all zero.
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
foreach (var q in MaintenanceService.MonitoredPoisonQueues)
|
||||
probe.Counts[q] = 0;
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -32,10 +35,40 @@ public class HealthCheckServicePoisonQueueTests
|
||||
Assert.Equal("Poison Queues", check.Name);
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Equal(MaintenanceService.MonitoredPoisonQueues.Length, check.Details!.Count);
|
||||
Assert.Equal(3, check.Details!.Count);
|
||||
Assert.All(check.Details.Values, v => Assert.Equal("0 messages", v));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NoPoisonQueuesExist_ReportsHealthy()
|
||||
{
|
||||
// Steady state on a healthy system: poison queues are created lazily on
|
||||
// first poison-move, so an empty enumeration means nothing ever failed.
|
||||
var svc = BuildService(new FakePoisonQueueProbe());
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Empty(check.Details!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnumerationFails_ReportsWarningWithError()
|
||||
{
|
||||
// A failed listing must never read as an all-clear.
|
||||
var probe = new FakePoisonQueueProbe
|
||||
{
|
||||
ListException = new InvalidOperationException("auth denied")
|
||||
};
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("warning", check.Status);
|
||||
Assert.Contains("enumeration failed", check.Message);
|
||||
Assert.Contains("auth denied", (string)check.Details!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SingleQueueWithBacklog_ReportsWarning()
|
||||
{
|
||||
@@ -155,6 +188,19 @@ public class HealthCheckServicePoisonQueueTests
|
||||
{
|
||||
public Dictionary<string, long> Counts { get; } = new();
|
||||
public Dictionary<string, Exception> ExceptionFor { get; } = new();
|
||||
public Exception? ListException { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> ListPoisonQueuesAsync(CancellationToken ct)
|
||||
{
|
||||
if (ListException is not null)
|
||||
throw ListException;
|
||||
IReadOnlyList<string> names = Counts.Keys
|
||||
.Concat(ExceptionFor.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(names);
|
||||
}
|
||||
|
||||
public Task<long> GetApproximateMessageCountAsync(string queueName, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -10,7 +9,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// of the parts that decide whether an operator gets paged, without standing up
|
||||
/// the full MaintenanceService dependency graph. Full-service integration is
|
||||
/// exercised by the live timer + ops event tables — what we guard here is the
|
||||
/// classifier math, the dedup-key extractor, and the watch-list inventory.
|
||||
/// classifier math and the dedup-key extractor. The watched queues themselves are
|
||||
/// enumerated dynamically at runtime (every existing <c>-poison</c> queue), so
|
||||
/// there is no static inventory left to guard.
|
||||
/// </summary>
|
||||
public class MaintenanceServicePoisonQueueTests
|
||||
{
|
||||
@@ -116,36 +117,6 @@ public class MaintenanceServicePoisonQueueTests
|
||||
Assert.Equal(string.Empty, MaintenanceService.ExtractQueueName(detailsJson));
|
||||
}
|
||||
|
||||
// --- Watch-list inventory ---
|
||||
|
||||
[Fact]
|
||||
public void MonitoredPoisonQueues_CoversAllProducerQueues()
|
||||
{
|
||||
// Guard against silently adding a new producer queue without a poison entry.
|
||||
// If a new queue is introduced, MonitoredPoisonQueues + this assertion must move together.
|
||||
Assert.Equal(6, MaintenanceService.MonitoredPoisonQueues.Length);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.VulnerabilityCorrelate + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// PR1 critical-table backup queue (plan §Wave5 #2).
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.CriticalTableBackupPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// Self-hosted poll-loop workers (no QueueTrigger) — see SessionDeletionWorker / TenantOffboardingWorker.
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.SessionDeletion + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TenantOffboardingPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AreCalibratedForStrictMode()
|
||||
{
|
||||
|
||||
@@ -131,14 +131,14 @@ public class McpQuotaServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
|
||||
public async Task Check_ProEdition_UsesProFallbackLimits()
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("enterprise", d.Plan);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(20000, d.MonthlyLimit);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class McpUserServiceDelegatedTests
|
||||
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
|
||||
+6
-2
@@ -180,10 +180,14 @@ public class OffboardingMarkerCleanupFunctionTests
|
||||
Assert.Equal(0, result.TenantConfigsSpared);
|
||||
Assert.Equal(0, result.TenantConfigSweepRetries);
|
||||
|
||||
// The sweep ran with the right table + tenant id.
|
||||
Assert.Single(fixture.SafeWipe.WipeCalls);
|
||||
// The sweep ran with the right tables + tenant id: the tombstone itself, plus the
|
||||
// tenant's config-backup partition (a save against the lingering tombstone may have
|
||||
// re-created snapshots after the cascade's bulk wipe).
|
||||
Assert.Equal(2, fixture.SafeWipe.WipeCalls.Count);
|
||||
Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].TenantId);
|
||||
Assert.Equal(Constants.TableNames.ConfigurationBackups, fixture.SafeWipe.WipeCalls[1].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[1].TenantId);
|
||||
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -279,6 +279,20 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
Assert.Contains(Constants.TableNames.TenantGroups, harness.SafeWipeProbe.PropertyOnlyWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_ExactPartitionWipe_CoversConfigurationBackups()
|
||||
{
|
||||
// Pre-write config snapshots (PK=tenantId) must die with the tenant — the row
|
||||
// contents include webhook URLs and the contact address. The bulk wipe runs
|
||||
// before the final TenantConfiguration delete, so no snapshot can be re-created
|
||||
// afterwards by the cascade itself.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
// ── Harness (copied minimal — only what these tests need) ───────────────────
|
||||
|
||||
private sealed class Harness
|
||||
@@ -425,12 +439,14 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public int WipeCallCount { get; private set; }
|
||||
/// <summary>Table names passed to the property-only (Variant C) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> PropertyOnlyWipes { get; } = new();
|
||||
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> ExactPartitionWipes { get; } = new();
|
||||
public CountingSafeWipeService() : base(
|
||||
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
NullLogger<BlobStorageService>.Instance, usesManagedIdentity: false),
|
||||
NullLogger<SafeWipeService>.Instance) { }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PlanManagementFunction.EvaluateTrialStart"/> — the self-service
|
||||
/// trial verdict, including the Pro-requires-contact gate. The check ORDER is semantic
|
||||
/// and pinned here: terminal conditions (consumed / already Pro) must win over the
|
||||
/// contact-address prompt, and the contact gate exists only at this entry point (never
|
||||
/// as a runtime lockout for existing Pro tenants).
|
||||
/// </summary>
|
||||
public class PlanManagementTrialGateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Contact = "it-ops@contoso.com";
|
||||
|
||||
[Fact]
|
||||
public void Allowed_WhenCommunityWithContactAndUnconsumedTrial()
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = Contact };
|
||||
|
||||
Assert.Null(PlanManagementFunction.EvaluateTrialStart(config, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Denied_ContactEmailRequired_WhenNoAddressStored(string? contact)
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = contact };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactEmailRequired", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_TrialAlreadyConsumed_WinsOverMissingContact()
|
||||
{
|
||||
// A consumed trial is terminal — prompting for a contact address would be pointless.
|
||||
var config = new TenantConfiguration { TrialConsumed = true, ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("TrialAlreadyConsumed", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_WinsOverMissingContact()
|
||||
{
|
||||
// Existing Pro tenants without a contact address are nagged via the dashboard
|
||||
// banner, never blocked — the verdict must name the Pro state, not the address.
|
||||
var config = new TenantConfiguration { PlanTier = "pro", ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_ForActiveTrialEdition()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TrialExpiresUtc = Now.AddDays(5),
|
||||
TrialConsumed = false, // hypothetical shape — edition resolution alone must deny
|
||||
ContactEmail = Contact,
|
||||
};
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
@@ -605,6 +605,87 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
|
||||
{
|
||||
const string upn = "ga@vendor.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantB}/fields"),
|
||||
("GET", $"/api/config/{TenantB}/backups"),
|
||||
("POST", $"/api/config/{TenantB}/revert"),
|
||||
})
|
||||
{
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, upn));
|
||||
Assert.True(result.Allowed, $"{method} {path} must admit a Global Admin");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFieldsSchema_LiteralRoute_WinsOverTenantIdTemplate()
|
||||
{
|
||||
// "fields-schema" must never be swallowed as a {tenantId} of the MemberRead config GET —
|
||||
// FindPolicy's literal-over-template precedence is what this feature's route relies on
|
||||
// (same mechanism as config/all).
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/fields-schema");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("config/fields-schema", entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldsSchema_GlobalAdminAllowed_ReaderAndTenantAdminForbidden()
|
||||
{
|
||||
var ga = BuildHarness();
|
||||
ga.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
Assert.True((await ga.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "ga@vendor.example"))).Allowed);
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
Assert.False((await reader.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "reader@vendor.example"))).Allowed);
|
||||
|
||||
var tenantAdmin = BuildHarness();
|
||||
tenantAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
Assert.False((await tenantAdmin.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "admin@contoso.com"))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
{
|
||||
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
|
||||
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantA}/fields"),
|
||||
("GET", $"/api/config/{TenantA}/backups"),
|
||||
("POST", $"/api/config/{TenantA}/revert"),
|
||||
})
|
||||
{
|
||||
var ownAdmin = BuildHarness();
|
||||
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
var readerResult = await reader.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "reader@vendor.example"));
|
||||
Assert.False(readerResult.Allowed, $"{method} {path} must deny a read-only Global Reader");
|
||||
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
|
||||
{
|
||||
@@ -672,6 +753,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed); // the delegated read itself is allowed…
|
||||
// …but no config row was persisted for the MSP's home tenant A (nor any tenant).
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the static helpers <c>PreviewWhitelistFunction.PickRequesterUpn</c>
|
||||
/// and <c>PreviewWhitelistFunction.IsRealUserUpn</c>.
|
||||
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
|
||||
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
|
||||
/// <para>
|
||||
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
|
||||
/// 10 tenants' TenantAdmins rows by writing the sentinel string
|
||||
@@ -31,7 +31,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("cadm_user@xx3t8.onmicrosoft.com")]
|
||||
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -46,7 +46,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
// The shape-based prefix check is exactly the defense against the corruption
|
||||
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
|
||||
// is rejected without anyone needing to remember to update this method.
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -57,7 +57,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("just-a-string")]
|
||||
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
|
||||
{
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -73,7 +73,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("onboarder@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,7 +85,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +97,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,8 +112,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
Assert.Equal("real.requester@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(PreviewWhitelistFunction.PickRequesterUpn(cfg)));
|
||||
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,8 +127,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
var picked = PreviewWhitelistFunction.PickRequesterUpn(cfg);
|
||||
var picked = TenantApprovalService.PickRequesterUpn(cfg);
|
||||
Assert.Equal("System (Global Rate Limit Sync)", picked);
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(picked));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -11,43 +9,24 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gate-coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — the const-false arm-flag short-circuits before any client
|
||||
/// construction. These tests instead guard the disarmed-by-default contract:
|
||||
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — every test drives one of the no-op gates. Pins:
|
||||
/// <list type="bullet">
|
||||
/// <item>The arm constant defaults to <c>false</c>, so an accidental merge cannot ship
|
||||
/// the placeholder template.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws under disarmed state —
|
||||
/// not even when the API key is empty or the recipient is null. The handler's
|
||||
/// fail-soft try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>The disarmed code path logs a debug-level "feature disarmed" line so operators
|
||||
/// have a positive signal in app insights that "the build is intentionally not
|
||||
/// sending farewell emails", as opposed to a silent regression.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
|
||||
/// is empty and not when the recipient is missing. The handler's fail-soft
|
||||
/// try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ResendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void OffboardFarewellEmailArmed_DefaultsToFalse()
|
||||
{
|
||||
// Guard the disarmed-by-design contract. Flipping this constant to true is the
|
||||
// explicit "scharf schalten" action the user does after finalising the template.
|
||||
// If this test fails, somebody flipped the flag — make sure the template, feedback
|
||||
// form, and unsubscribe story are all signed off before letting the change land.
|
||||
var field = typeof(ResendEmailService).GetField(
|
||||
"OffboardFarewellEmailArmed",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
var armed = (bool)field!.GetRawConstantValue()!;
|
||||
Assert.False(armed,
|
||||
"OffboardFarewellEmailArmed must default to false until the offboarding farewell template + feedback-form story is signed off.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_DoesNotThrow_AndLogsDisarmedDebugLine()
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
@@ -56,31 +35,11 @@ public sealed class ResendEmailServiceTests
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyApiKey_StillNoOps()
|
||||
{
|
||||
// Even with no API key configured the disarmed early-return fires FIRST — so the
|
||||
// missing-key debug line must NOT show up; only the disarmed line does. This pins
|
||||
// the gate ordering: disarmed > missing-key > missing-email.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyRecipient_StillNoOps()
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
@@ -91,11 +50,58 @@ public sealed class ResendEmailServiceTests
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RevokeEnforcementTests
|
||||
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
|
||||
new(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the merge collision guard in <see cref="AnalyzeRuleService.GetAllRulesForTenantAsync"/>
|
||||
/// and <see cref="GatherRuleService.GetAllRulesForTenantAsync"/>: a tenant-partition row whose
|
||||
/// RuleId collides with a merged global rule (legacy debris from the pre-global-partition
|
||||
/// seeding era, e.g. tenant 5ca2b350's ANALYZE-ID-002 copy from 2026-03) must be skipped —
|
||||
/// the global definition wins. Without the guard the same RuleId appears twice in the merged
|
||||
/// list and every ToDictionary(r => r.RuleId) consumer throws (prod: ArgumentException in
|
||||
/// AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync, killing rule notifications).
|
||||
/// </summary>
|
||||
public class RuleMergeCollisionGuardTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInAnalyzeRules.GetAll().First().RuleId;
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
// The exact downstream consumer that crashed in prod must work again.
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInGatherRules.GetAll().First().RuleId;
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static AnalyzeRule StaleTenantAnalyzeCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule StaleTenantGatherCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Category = "test",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\X",
|
||||
Trigger = "startup",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
/// <summary>DB state = exactly the shipped catalog (clean seed diff), plus the given tenant rows.</summary>
|
||||
private static Mock<IRuleRepository> CreateAnalyzeRepo(List<AnalyzeRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(BuiltInAnalyzeRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
|
||||
private static Mock<IRuleRepository> CreateGatherRepo(List<GatherRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(BuiltInGatherRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteGatherRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
@@ -483,18 +483,29 @@ public class SessionDeletionWorkerTests
|
||||
};
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(envelope), dequeueCount: 1);
|
||||
|
||||
// Handler hangs for 350ms so the heartbeat task ticks at least 5×.
|
||||
// De-flake (CI: a fixed 350ms handler window raced the 50ms heartbeat timer on loaded
|
||||
// runners — the handler could finish before 3 heartbeats fired, after which the awaited
|
||||
// condition can never become true). Instead the handler holds the message in-flight
|
||||
// UNTIL the heartbeats have been observed: the heartbeat task ticks for as long as the
|
||||
// handler runs, so the condition is reached deterministically regardless of scheduler
|
||||
// jitter. A genuinely broken heartbeat leaves the loop to the worker's cancellation at
|
||||
// RunUntilAsync's ceiling, and the Times.AtLeast(3) below fails as intended.
|
||||
harness.HandlerMock.Setup(h => h.HandleAsync(
|
||||
It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionDeletionEnvelope _, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(350), ct);
|
||||
// Own deadline besides ct: if the heartbeat were broken AND shutdown never
|
||||
// cancelled the handler token, the test must still terminate (and then fail
|
||||
// on the verify) instead of hanging in StopAsync.
|
||||
var handlerDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
||||
while (!ct.IsCancellationRequested && harness.HeartbeatCount() < 3 && DateTime.UtcNow < handlerDeadline)
|
||||
await Task.Delay(10, CancellationToken.None);
|
||||
});
|
||||
|
||||
await harness.RunUntilAsync(() => harness.HeartbeatCount() >= 3);
|
||||
|
||||
// Heartbeat called at least 3× while the handler was busy (350ms / 50ms ≈ 7, minus a few
|
||||
// due to scheduling jitter). One call is enough to prove the heartbeat task is wired.
|
||||
// Heartbeat fired at least 3× while the handler was busy — proves the heartbeat task
|
||||
// is wired and keeps extending visibility for the whole duration of a long handler run.
|
||||
harness.MainQueue.Verify(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Plausibility guard for the session anchor (session df1fcf47): ONE do_telemetry event whose
|
||||
/// OccurredUtc inherited a tz-shifted IME log line 8h in the past dragged StartedAt back ~7h
|
||||
/// and inflated the WG Part 1 DurationSeconds from ~1h16m to 8h15m. The earliest-event
|
||||
/// timestamp — whether from the ingest batch or the Events-table probe — may only re-anchor
|
||||
/// StartedAt / the duration start when it sits within
|
||||
/// <see cref="TableStorageService.MaxStartedAtBackwardShift"/> of the current anchor.
|
||||
/// </summary>
|
||||
public class SessionStartedAtBackwardShiftGuardTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static readonly DateTime StartedAt = new(2026, 7, 29, 18, 45, 15, DateTimeKind.Utc);
|
||||
|
||||
// ==================================================== StartedAt alignment ====
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_rejects_backward_shift_beyond_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// The df1fcf47 shape: one event 8h before the session's real start.
|
||||
var skewed = StartedAt.AddHours(-7);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: skewed, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.False(harness.Written!.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_accepts_backward_shift_within_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// Legitimate pre-registration backlog: minutes, not hours.
|
||||
var earlier = StartedAt.AddMinutes(-30);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: earlier, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal(new DateTimeOffset(earlier), harness.Written!.GetDateTimeOffset("StartedAt"));
|
||||
}
|
||||
|
||||
// ============================================= Pending (WG Part 1) duration ====
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_ignores_skewed_stored_earliest_event()
|
||||
{
|
||||
// Exact df1fcf47 replica: the poisoned event is already IN the Events table
|
||||
// (probe returns it) AND rides in the batch as earliestEventTimestamp.
|
||||
var poisoned = StartedAt.AddHours(-7);
|
||||
var lastEvent = new DateTime(2026, 7, 29, 20, 1, 9, DateTimeKind.Utc);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: poisoned);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
earliestEventTimestamp: poisoned, latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
// Duration anchors on StartedAt (18:45:15 → 20:01:09 = 4554s), not on the skewed
|
||||
// event (which would give 29690s less the 7h offset — the original inflation).
|
||||
Assert.Equal((int)(lastEvent - StartedAt).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
Assert.False(harness.Written.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_uses_plausible_stored_earliest_event()
|
||||
{
|
||||
var earliest = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: earliest);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private static TableEntity SessionRow()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
["Status"] = "InProgress",
|
||||
["EventCount"] = 10,
|
||||
};
|
||||
entity.ETag = new ETag("0xEXISTING");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK-mock harness (same shape as <see cref="StoreSessionReregistrationPreserveTests"/>):
|
||||
/// Sessions table configured for read + merge-capture; Events table optionally serves the
|
||||
/// earliest-event probe; SessionsIndex stays unconfigured — its sync is swallow-all in the
|
||||
/// SUT and irrelevant here.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
|
||||
{
|
||||
var sessions = new Mock<TableClient>();
|
||||
|
||||
sessions.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existing, new Mock<Response>().Object));
|
||||
|
||||
var ifExists = new Mock<NullableResponse<TableEntity>>();
|
||||
ifExists.SetupGet(r => r.HasValue).Returns(true);
|
||||
ifExists.SetupGet(r => r.Value).Returns(existing);
|
||||
sessions.Setup(t => t.GetEntityIfExistsAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ifExists.Object);
|
||||
|
||||
sessions.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
Written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var events = new Mock<TableClient>();
|
||||
var eventRows = earliestStoredEvent.HasValue
|
||||
? new[]
|
||||
{
|
||||
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
|
||||
{
|
||||
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
|
||||
},
|
||||
}
|
||||
: Array.Empty<TableEntity>();
|
||||
events.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
|
||||
{
|
||||
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
|
||||
}));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Events)).Returns(events.Object);
|
||||
// SessionsIndex intentionally unconfigured → null client → SUT swallows.
|
||||
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TenantApprovalService.ApproveWithSideEffectsAsync"/> — the
|
||||
/// idempotency contract: the conditional whitelist insert decides which caller "wins" a
|
||||
/// concurrent activation, and only the winner runs the side effects (auto-promote,
|
||||
/// welcome mail). The loser returns false and touches nothing.
|
||||
/// </summary>
|
||||
public class TenantApprovalServiceTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string ApprovedBy = "ga@contoso.com";
|
||||
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<ResendEmailService> _resendMock;
|
||||
private readonly TenantApprovalService _sut;
|
||||
|
||||
public TenantApprovalServiceTests()
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_resendMock = new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
_resendMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyApproved_ReturnsFalse_AndRunsNoSideEffects()
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.False(newlyApproved);
|
||||
// The side-effect block (auto-promote + welcome mail) starts with the config read —
|
||||
// it must never run for a lost activation race.
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_ReturnsTrue()
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
// Default config has no OnboardedBy/UpdatedBy UPN → auto-promote is skipped by the
|
||||
// shape guard; no notification email → no mail. Only the return value is under test.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
|
||||
// notification-email save path. Ordering contract under test: the sent-marker must be
|
||||
// consumed strictly AFTER the address check (a marker consumed with no address would
|
||||
// permanently suppress the mail), and a lost marker race must not send.
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_NoAddressYet_DefersWithoutConsumingMarker()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_AddressPresent_MarkerWon_SendsOnce()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_MarkerAlreadyConsumed_DoesNotSend()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(false);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_MarkerStorageError_ReturnsFalse_WithoutSending()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveWithSideEffects_AddressAlreadySaved_SendsWelcomeMail()
|
||||
{
|
||||
// The user saved their notification email BEFORE activation (manual-approve shape):
|
||||
// the approval path itself must send.
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Activation;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TenantAutoApproveHandler"/> — the consumer logic of the
|
||||
/// <c>tenant-auto-approve</c> queue. Every early return must DROP (never approve);
|
||||
/// only the happy path may call the shared activation service, and it must use the
|
||||
/// "System (auto-approve)" sentinel that the UPN shape-guard rejects as a promotable user.
|
||||
/// </summary>
|
||||
public class TenantAutoApproveHandlerTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "first.user@contoso.com";
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantApprovalService> _approvalMock;
|
||||
private readonly List<OpsEventEntry> _savedOpsEvents = new();
|
||||
private readonly TenantAutoApproveHandler _sut;
|
||||
|
||||
public TenantAutoApproveHandlerTests()
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_adminConfigMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_approvalMock = new Mock<TenantApprovalService>(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance).Object)
|
||||
{ CallBase = false };
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new TenantAutoApproveHandler(
|
||||
NullLogger<TenantAutoApproveHandler>.Instance,
|
||||
_adminConfigMock.Object,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_approvalMock.Object,
|
||||
opsService);
|
||||
|
||||
// Defaults: flag on, tenant not yet approved, config exists and is enabled. The
|
||||
// handler reads the config via the cache-bypassing GetConfigurationFreshAsync — the
|
||||
// suspension gate must never decide on a stale per-instance cache.
|
||||
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(false);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
}
|
||||
|
||||
private static TenantAutoApproveEnvelope Envelope() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SignupUpn = Upn,
|
||||
EnqueuedAtUtc = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task FlagOff_DropsWithoutApproving()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(false);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyApproved_Drops()
|
||||
{
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(true);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantConfigMissing_Drops()
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendedTenant_Drops()
|
||||
{
|
||||
var suspended = TenantConfiguration.CreateDefault(TenantId);
|
||||
suspended.Disabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync(suspended);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigReadError_Propagates_SoTheMessageRetries()
|
||||
{
|
||||
// A storage blip must NOT silently drop the message (that would permanently
|
||||
// downgrade the tenant to manual approval) — it throws, the queue retries, and
|
||||
// the flag + suspension gates re-run on every attempt.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage blip"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.HandleAsync(Envelope(), CancellationToken.None));
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HappyPath_ApprovesWithSentinel_AndEmitsOpsEvent()
|
||||
{
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(
|
||||
x => x.ApproveWithSideEffectsAsync(TenantId, TenantAutoApproveHandler.AutoApprovedBy),
|
||||
Times.Once);
|
||||
|
||||
var evt = Assert.Single(_savedOpsEvents);
|
||||
Assert.Equal("TenantAutoApproved", evt.EventType);
|
||||
Assert.Equal(TenantId, evt.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivationRaceLost_DropsWithoutOpsEvent()
|
||||
{
|
||||
// Double signup enqueues two envelopes; the conditional whitelist insert lets
|
||||
// exactly one worker win. The loser must not emit a second TenantAutoApproved
|
||||
// ops event (the winner's side effects already ran).
|
||||
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApprovedBySentinel_IsRejectedByUpnShapeGuard()
|
||||
{
|
||||
// The sentinel must never pass the promotable-user shape check — that is the
|
||||
// defense against writing a system string into the TenantAdmins table.
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(TenantAutoApproveHandler.AutoApprovedBy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transactional config patch/revert: field gates, fail-closed backup, CAS retry,
|
||||
/// exactly-these-fields verification with automatic rollback, and the phase-2
|
||||
/// caller-tier deny-lists. The harness emulates real table CAS semantics (versioned
|
||||
/// ETags, conditional replace) so the retry/rollback paths are exercised honestly.
|
||||
/// </summary>
|
||||
public class TenantConfigPatchServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Ga = "ga@operator.example";
|
||||
|
||||
/// <summary>In-memory row with real CAS semantics + optional write-time corruption hook.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<IConfigRepository> Repo { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public TenantConfigPatchService Sut { get; }
|
||||
|
||||
public TenantConfiguration? Current;
|
||||
public int Version = 1;
|
||||
public List<ConfigBackupEntry> Backups { get; } = new();
|
||||
public int ReplaceCalls;
|
||||
/// <summary>Applied to the stored row on every successful replace — simulates serialization drift.</summary>
|
||||
public Func<TenantConfiguration, TenantConfiguration>? CorruptOnWrite;
|
||||
/// <summary>Bumps the version WITHOUT a replace before the Nth replace call — simulates a concurrent writer.</summary>
|
||||
public int? StealEtagBeforeReplaceCall;
|
||||
|
||||
public Harness(TenantConfiguration? stored)
|
||||
{
|
||||
Current = stored;
|
||||
|
||||
Repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => Current == null
|
||||
? ((TenantConfiguration, string)?)null
|
||||
: (Clone(Current), $"etag-{Version}"));
|
||||
|
||||
Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
|
||||
.Returns<TenantConfiguration, string>((config, etag) =>
|
||||
{
|
||||
ReplaceCalls++;
|
||||
if (StealEtagBeforeReplaceCall == ReplaceCalls)
|
||||
{
|
||||
Version++; // the concurrent writer got there first
|
||||
StealEtagBeforeReplaceCall = null;
|
||||
}
|
||||
if (etag != $"etag-{Version}")
|
||||
return Task.FromResult(false);
|
||||
Current = CorruptOnWrite != null ? CorruptOnWrite(Clone(config)) : Clone(config);
|
||||
Version++;
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => Backups.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
Backup.Setup(b => b.ListByPartitionAsync(TenantId, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => Backups.AsEnumerable().Reverse().ToList()); // newest first
|
||||
Backup.Setup(b => b.TryGetAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, CancellationToken>((_, id, _) =>
|
||||
Task.FromResult(Backups.FirstOrDefault(b => b.RowKey == id)));
|
||||
|
||||
Maintenance.Setup(m => m.LogAuditEntryAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var configService = new TenantConfigurationService(
|
||||
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Sut = new TenantConfigPatchService(
|
||||
Repo.Object, Backup.Object, configService, Maintenance.Object,
|
||||
NullLogger<TenantConfigPatchService>.Instance);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Clone(TenantConfiguration c)
|
||||
=> JsonConvert.DeserializeObject<TenantConfiguration>(JsonConvert.SerializeObject(c))!;
|
||||
}
|
||||
|
||||
private static TenantConfiguration Stored()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
private static JObject Fields(params (string Key, object? Value)[] fields)
|
||||
{
|
||||
var o = new JObject();
|
||||
foreach (var (key, value) in fields)
|
||||
o[key] = value == null ? JValue.CreateNull() : JToken.FromObject(value);
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_SingleField_Applies_BacksUp_Verifies_Audits()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "retention bump");
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(new[] { "DataRetentionDays" }, outcome.AppliedFields);
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
Assert.Equal(Ga, harness.Current.UpdatedBy);
|
||||
Assert.NotNull(outcome.BackupId);
|
||||
var backup = Assert.Single(harness.Backups);
|
||||
Assert.Equal("mcp-patch", backup.Source);
|
||||
Assert.Equal("retention bump", backup.Reason);
|
||||
Assert.Contains("\"DataRetentionDays\":30", backup.EntityJson); // snapshot = PRE-write state
|
||||
Assert.True(outcome.MaskedDiff!.ContainsKey("DataRetentionDays"));
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
TenantId, "PATCH", "TenantConfiguration", TenantId, Ga,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["BackupId"] == outcome.BackupId)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NullValue_ClearsNullableField()
|
||||
{
|
||||
var stored = Stored();
|
||||
stored.ContactEmail = "ops@contoso.com";
|
||||
var harness = new Harness(stored);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("contactEmail", null)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(harness.Current!.ContactEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 30)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Empty(outcome.AppliedFields);
|
||||
Assert.Null(outcome.BackupId);
|
||||
Assert.Empty(harness.Backups);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
// ── Field gate ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("planTier")]
|
||||
[InlineData("trialExpiresUtc")]
|
||||
[InlineData("homedAppClientId")]
|
||||
[InlineData("lastAuthClientId")]
|
||||
[InlineData("onboardedBy")]
|
||||
[InlineData("tenantId")]
|
||||
[InlineData("domainName")]
|
||||
[InlineData("lastUpdated")]
|
||||
[InlineData("updatedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields((field, "x")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains(field, outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(harness.Backups);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnknownField_Returns400WithFieldName()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("definitelyNotAField", 1)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains("definitelyNotAField", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_RedactedPlaceholderValue_Rejected()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("webhookUrl", Constants.RedactedSecretPlaceholder)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains("redacted", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_GaOnlyField_AllowedForGa_DeniedForTenantAdminTier()
|
||||
{
|
||||
var gaHarness = new Harness(Stored());
|
||||
var gaOutcome = await gaHarness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("bootstrapTokenEnabled", true)), Ga, "mcp-patch", null,
|
||||
TenantConfigCallerTier.GlobalAdmin);
|
||||
Assert.True(gaOutcome.Success);
|
||||
|
||||
foreach (var tier in new[] { TenantConfigCallerTier.TenantAdmin, TenantConfigCallerTier.DelegatedAdmin })
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("bootstrapTokenEnabled", true)), "admin@contoso.com", "api-patch", null, tier);
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_InvalidWebhookUrl_ValidationFails_NoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("webhookUrl", "http://169.254.169.254/latest")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
Assert.Empty(harness.Backups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_BadNotificationChannelsJson_ValidationFails()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("notificationChannelsJson", "{not json")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
var harness = new Harness(Stored()); // gate (UnrestrictedModeEnabled) is false by default
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("unrestrictedMode", true)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("UnrestrictedModeEnabled", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
// ── Storage edges ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NoConfigRow_NotFound()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_BackupFailure_IsFailClosed_NoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("backup table down"));
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.BackupFailed, outcome.Failure);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_CasRace_RetriesAndSucceeds()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.StealEtagBeforeReplaceCall = 1; // first replace loses, retry wins
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(2, harness.ReplaceCalls);
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_CasRaceEveryAttempt_ReturnsConflict()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false); // permanent race
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.WriteConflict, outcome.Failure);
|
||||
}
|
||||
|
||||
// ── Verify + rollback ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_DriftOnWrite_RollsBackToInitial_AndReportsDriftNames()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
// Simulated serialization drift: every write silently zeroes SessionTimeoutHours.
|
||||
// The verify must catch the unexpected field and roll the row back. (The rollback
|
||||
// write itself is also corrupted by the hook, but rollback success is judged by the
|
||||
// CAS result — clear the hook after the first corruption so the rollback restores.)
|
||||
harness.CorruptOnWrite = written =>
|
||||
{
|
||||
harness.CorruptOnWrite = null;
|
||||
written.SessionTimeoutHours = 0;
|
||||
return written;
|
||||
};
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.DriftRolledBack, outcome.Failure);
|
||||
Assert.Contains(outcome.Drift!, d => d.Contains("SessionTimeoutHours"));
|
||||
Assert.NotNull(outcome.BackupId); // pre-write snapshot exists for manual inspection
|
||||
// The row is back at the initial state: retention 30, timeout restored.
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays);
|
||||
Assert.Equal(Stored().SessionTimeoutHours, harness.Current.SessionTimeoutHours);
|
||||
// No success audit for a rolled-back write.
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
It.IsAny<string>(), "PATCH", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_DriftAndRollbackLosesRace_ReportsManualRestoreWithBackupId()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.CorruptOnWrite = written => { written.SessionTimeoutHours = 0; return written; };
|
||||
// Every write corrupts — including the rollback attempt — but the decisive part:
|
||||
// steal the ETag before the rollback replace so the rollback itself loses the race.
|
||||
harness.StealEtagBeforeReplaceCall = 2;
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.DriftRollbackFailed, outcome.Failure);
|
||||
Assert.Contains(outcome.BackupId!, outcome.Error);
|
||||
}
|
||||
|
||||
// ── Revert ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(Harness Harness, string BackupId)> PatchedHarnessAsync()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
var patch = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "bump");
|
||||
Assert.True(patch.Success);
|
||||
return (harness, patch.BackupId!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_Latest_RestoresPatchedField_AndBacksUpCurrentFirst()
|
||||
{
|
||||
var (harness, _) = await PatchedHarnessAsync();
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // back to the pre-patch value
|
||||
Assert.Equal(2, harness.Backups.Count); // revert snapshotted the 90-state first
|
||||
Assert.Contains("\"DataRetentionDays\":90", harness.Backups[1].EntityJson);
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
TenantId, "REVERT", "TenantConfiguration", TenantId, Ga,
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_ByBackupId_UsesThatSnapshot()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_PreservesProtectedFields_ByDefault()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
// Plan + homing changed AFTER the snapshot (via their dedicated flows).
|
||||
harness.Current!.PlanTier = "enterprise";
|
||||
harness.Current.HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // reverted
|
||||
Assert.Equal("enterprise", harness.Current.PlanTier); // protected — kept current
|
||||
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", harness.Current.HomedAppClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_IncludeProtectedFields_RestoresThemToo()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
harness.Current!.PlanTier = "enterprise";
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: true, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(Stored().PlanTier, harness.Current!.PlanTier); // snapshot value restored
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_NoBackups_NotFound()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_UnknownBackupId_NotFound()
|
||||
{
|
||||
var (harness, _) = await PatchedHarnessAsync();
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, "does-not-exist", includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
// ── Rehydration roundtrip (serialization-drift canary) ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_Roundtrip_EveryPropertyNonNull_ProducesIdenticalModel()
|
||||
{
|
||||
// Reflection filler: EVERY writable property gets a non-default value so no column
|
||||
// escapes the fidelity check. Whole-number decimals (95m) are the deliberate trap —
|
||||
// JSON serializes 95.0 as "95", and prod finding 2026-08-03 was exactly a whole-valued
|
||||
// SLA rate column rehydrating as Int32 and blowing up TableEntity.GetDouble.
|
||||
var original = TenantConfiguration.CreateDefault(TenantId);
|
||||
foreach (var prop in typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0))
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
object? value =
|
||||
t == typeof(string) ? (prop.Name is "PartitionKey" or "RowKey" or "TenantId"
|
||||
? prop.GetValue(original) : $"x-{prop.Name}")
|
||||
: t == typeof(bool) ? true
|
||||
: t == typeof(int) ? 7
|
||||
: t == typeof(decimal) ? 95m // whole number on purpose — the Int32/Double trap
|
||||
: t == typeof(double) ? 95d
|
||||
: t == typeof(DateTime) ? new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc)
|
||||
: prop.GetValue(original);
|
||||
prop.SetValue(original, value);
|
||||
}
|
||||
|
||||
// Canonical baseline = one Store/Map roundtrip (isolates REHYDRATION fidelity from any
|
||||
// pre-existing converter quirks, which TenantConfigTableSerializationTests own).
|
||||
var baseline = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original));
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(baseline),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer
|
||||
.GetChangedPropertyNames(baseline, rehydrated);
|
||||
Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_WholeNumberDecimalColumn_SurvivesAsDouble()
|
||||
{
|
||||
// Focused pin on the prod failure: SlaTargetSuccessRate 95 (whole) must come back as
|
||||
// a readable double column, not an Int32 that makes GetDouble throw.
|
||||
var original = Stored();
|
||||
original.SlaTargetSuccessRate = 95m;
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
Assert.Equal(95m, rehydrated.SlaTargetSuccessRate);
|
||||
}
|
||||
|
||||
// ── Fields schema (MCP steering surface) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_CoversEveryModelProperty_ExactlyOnce()
|
||||
{
|
||||
// Drift canary: the schema is reflection-generated, so this pins that EVERY writable
|
||||
// model property surfaces exactly once under its camelCase name — a new config field
|
||||
// is automatically schema-visible, and a rename cannot leave a stale entry.
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema();
|
||||
var expected = typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => char.ToLowerInvariant(p.Name[0]) + p.Name.Substring(1))
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(expected, schema.Select(f => f.Name).ToList());
|
||||
Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
|
||||
{
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema()
|
||||
.ToDictionary(f => f.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var denied in TenantConfigPatchService.BaseDeniedFields
|
||||
.Where(n => n is not ("PartitionKey" or "RowKey" or "Timestamp" or "ETag")))
|
||||
{
|
||||
Assert.False(schema[denied].Writable, $"{denied} must be marked non-writable");
|
||||
Assert.False(string.IsNullOrWhiteSpace(schema[denied].Reason), $"{denied} needs a deny reason");
|
||||
}
|
||||
foreach (var gaOnly in TenantConfigPatchService.GaOnlyFields)
|
||||
{
|
||||
Assert.True(schema[gaOnly].Writable, $"{gaOnly} is writable for GA");
|
||||
Assert.True(schema[gaOnly].GaOnly, $"{gaOnly} must carry the GA-only marker");
|
||||
}
|
||||
foreach (var protectedField in TenantConfigPatchService.RevertProtectedFields)
|
||||
{
|
||||
Assert.True(schema[protectedField].RevertProtected, $"{protectedField} must carry the revert-protected marker");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_TypeExemplars_AreCorrect()
|
||||
{
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema()
|
||||
.ToDictionary(f => f.Name, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(("integer", false, true), (schema["dataRetentionDays"].Type, schema["dataRetentionDays"].Nullable, schema["dataRetentionDays"].Writable));
|
||||
// The prod-finding column class: decimal? → JSON "number", nullable.
|
||||
Assert.Equal(("number", true), (schema["slaTargetSuccessRate"].Type, schema["slaTargetSuccessRate"].Nullable));
|
||||
Assert.Equal(("boolean", true), (schema["disabled"].Type, schema["disabled"].GaOnly));
|
||||
Assert.Equal(("string", true), (schema["contactEmail"].Type, schema["contactEmail"].Nullable));
|
||||
Assert.Equal("json", schema["notificationChannelsJson"].Format);
|
||||
Assert.Equal("date-time", schema["trialExpiresUtc"].Format);
|
||||
Assert.False(schema["planTier"].Writable);
|
||||
Assert.Contains("plan/trial", schema["planTier"].Reason);
|
||||
Assert.False(schema["lastUpdated"].Writable);
|
||||
Assert.False(schema["homedAppClientId"].Writable);
|
||||
Assert.True(schema["homedAppClientId"].RevertProtected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_DateLookingStringField_StaysString()
|
||||
{
|
||||
var original = Stored();
|
||||
original.DisabledReason = "2026-08-03T12:00:00+00:00"; // adversarial: a string that parses as a date
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
Assert.Equal(original.DisabledReason, rehydrated.DisabledReason);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public class TenantConfigProjectionTests
|
||||
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed",
|
||||
"disabled", "disabledReason",
|
||||
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -146,4 +146,40 @@ public class TenantConfigTableSerializationTests
|
||||
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
||||
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).NotificationChannelsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_AppRegHomingFields_SurviveStoreAndMap()
|
||||
{
|
||||
var since = new DateTime(2026, 7, 31, 9, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
|
||||
LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
|
||||
LastAuthClientIdSince = since
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.HomedAppClientId);
|
||||
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.LastAuthClientId);
|
||||
Assert.Equal(since, mapped.LastAuthClientIdSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_LegacyRow_WithoutAppRegHomingColumns_ReadsNull()
|
||||
{
|
||||
// Every tenant row pre-dating the C4A8 app-reg move lacks these columns; null
|
||||
// HomedAppClientId is the invariant for "homed on the legacy app registration".
|
||||
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
||||
|
||||
Assert.Null(mapped.HomedAppClientId);
|
||||
Assert.Null(mapped.LastAuthClientId);
|
||||
Assert.Null(mapped.LastAuthClientIdSince);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -109,7 +109,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
public async Task SaveConfigurationAsync_RepoReportsFalse_Throws()
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
@@ -125,7 +125,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
var (service, repo) = Build();
|
||||
var stored = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", UpdatedBy = "x" };
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(stored);
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var cached = await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
@@ -144,7 +144,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
var (service, repo) = Build();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
|
||||
@@ -188,6 +188,8 @@ public class TenantContactEmailTests
|
||||
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -285,6 +287,7 @@ public class TenantContactEmailTests
|
||||
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
Mock.Of<IConfigBackupRepository>(),
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class TenantEntitlementServiceTests
|
||||
public async Task GetEdition_EnterpriseTier_ReturnsEnterprise()
|
||||
{
|
||||
var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
|
||||
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
|
||||
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -46,7 +46,7 @@ public class TenantEntitlementServiceTests
|
||||
PlanTier = "free",
|
||||
TrialExpiresUtc = Now.AddDays(3)
|
||||
});
|
||||
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
|
||||
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -87,6 +87,8 @@ public class TenantEntitlementServiceTests
|
||||
var (svc, repo) = Build(config: null);
|
||||
await svc.GetEditionAsync(TenantId);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -111,15 +113,78 @@ public class TenantEntitlementServiceTests
|
||||
[InlineData("free", 60, 60)] // below the cap — unchanged
|
||||
[InlineData("free", 91, 90)] // above the Community cap — clamped
|
||||
[InlineData("free", 180, 90)] // legacy stored value above cap — clamped
|
||||
[InlineData("enterprise", 180, 180)]
|
||||
[InlineData("enterprise", 365, 365)]
|
||||
[InlineData("enterprise", 400, 365)] // above the Enterprise cap — clamped
|
||||
[InlineData("pro", 180, 180)]
|
||||
[InlineData("pro", 365, 365)]
|
||||
[InlineData("pro", 400, 365)] // above the Pro cap — clamped
|
||||
[InlineData("enterprise", 180, 180)] // legacy stored value — Pro cap applies
|
||||
[InlineData("enterprise", 400, 365)]
|
||||
public void GetEffectiveRetentionDays_ClampsToEditionCap(string tier, int stored, int expected)
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, DataRetentionDays = stored };
|
||||
Assert.Equal(expected, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
}
|
||||
|
||||
// ── IsBootstrapEnabled / IsUnrestrictedModeActive (effective feature gates) ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", false, true)] // included in the plan — no GA flag needed
|
||||
[InlineData("enterprise", false, true)] // legacy stored value counts as Pro
|
||||
[InlineData("free", true, true)] // Community + GA flag (additive escape hatch)
|
||||
[InlineData("free", false, false)] // Community without flag → off
|
||||
public void IsBootstrapEnabled_PlanOrFlag(string tier, bool gaFlag, bool expected)
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, BootstrapTokenEnabled = gaFlag };
|
||||
Assert.Equal(expected, TenantEntitlementService.IsBootstrapEnabled(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBootstrapEnabled_ActiveTrial_CountsAsPro_AndExpiryTurnsItOff()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "free",
|
||||
BootstrapTokenEnabled = false,
|
||||
TrialExpiresUtc = Now.AddDays(1)
|
||||
};
|
||||
Assert.True(TenantEntitlementService.IsBootstrapEnabled(config, Now));
|
||||
Assert.False(TenantEntitlementService.IsBootstrapEnabled(config, Now.AddDays(2)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", true, true, true)] // all three conditions met
|
||||
[InlineData("pro", true, false, false)] // tenant admin has not opted in
|
||||
[InlineData("pro", false, true, false)] // GA on-request gate missing
|
||||
[InlineData("free", true, true, false)] // edition re-gate: Community never unrestricted
|
||||
public void IsUnrestrictedModeActive_RequiresProAndGateAndToggle(
|
||||
string tier, bool gate, bool toggle, bool expected)
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = tier,
|
||||
UnrestrictedModeEnabled = gate,
|
||||
UnrestrictedMode = toggle
|
||||
};
|
||||
Assert.Equal(expected, TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUnrestrictedModeActive_TrialExpiry_RearmsGuardrails()
|
||||
{
|
||||
// A trial tenant may have Unrestricted Mode granted+enabled; expiry must fail closed.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "free",
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
TrialExpiresUtc = Now.AddDays(1)
|
||||
};
|
||||
Assert.True(TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
|
||||
Assert.False(TenantEntitlementService.IsUnrestrictedModeActive(config, Now.AddDays(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_ZeroInfinite_IsNeverClamped()
|
||||
{
|
||||
|
||||
@@ -641,8 +641,8 @@ public sealed class TenantOffboardFunctionTests
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => saved = c)
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Seed cache with stale "Disabled=false" entry so we can prove invalidation.
|
||||
@@ -671,7 +671,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
// Idempotent: no Save thrash on resume-clicks while the marker is already in flight.
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -694,7 +694,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage save failed"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
@@ -713,8 +713,8 @@ public sealed class TenantOffboardFunctionTests
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => saved = c)
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
@@ -736,7 +736,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // transient storage rejection — NOT a throw.
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
@@ -752,7 +752,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
@@ -771,7 +771,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -781,7 +781,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = false;
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
@@ -832,7 +832,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
notYetDisabled.Disabled = false;
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(notYetDisabled);
|
||||
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
@@ -1044,6 +1044,6 @@ public sealed class TenantOffboardFunctionTests
|
||||
Assert.Equal(originalEarliestProcessingAt, historyAfter.EarliestProcessingAt);
|
||||
|
||||
// No Save call (the tombstone was already correct).
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
// surfaced via the Global Admin /admin/customs-archive page.
|
||||
services.AddSingleton<ITenantCustomsArchiveRepository, TableTenantCustomsArchiveRepository>();
|
||||
|
||||
// Pre-write snapshots of TenantConfiguration/AdminConfiguration rows;
|
||||
// constructor dependency of TableConfigRepository's save hook.
|
||||
services.AddSingleton<IConfigBackupRepository, TableConfigBackupRepository>();
|
||||
|
||||
// Graph add-on permission feature: per-tenant cache of Intune script display names.
|
||||
services.AddSingleton<IScriptNameCacheRepository, TableScriptNameCacheRepository>();
|
||||
return services;
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage backend for <see cref="IConfigBackupRepository"/>.
|
||||
/// Fail-loud — callers decide whether a snapshot failure is soft (legacy save hook)
|
||||
/// or hard (transactional patch/revert, which must not write without a backup).
|
||||
/// </summary>
|
||||
public sealed class TableConfigBackupRepository : IConfigBackupRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableConfigBackupRepository> _logger;
|
||||
|
||||
public TableConfigBackupRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableConfigBackupRepository> logger)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.ConfigurationBackups);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task UpsertAsync(ConfigBackupEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
if (entry == null) throw new ArgumentNullException(nameof(entry));
|
||||
if (string.IsNullOrEmpty(entry.PartitionKey)) throw new ArgumentException("PartitionKey required", nameof(entry));
|
||||
if (string.IsNullOrEmpty(entry.RowKey)) throw new ArgumentException("RowKey required", nameof(entry));
|
||||
return _tableClient.UpsertEntityAsync(Store(entry), TableUpdateMode.Replace, ct);
|
||||
}
|
||||
|
||||
public async Task<List<ConfigBackupEntry>> ListByPartitionAsync(
|
||||
string partitionKey, int max = 25, CancellationToken ct = default)
|
||||
{
|
||||
if (max < 1) throw new ArgumentOutOfRangeException(nameof(max));
|
||||
|
||||
var results = new List<ConfigBackupEntry>();
|
||||
var filter = $"PartitionKey eq '{Escape(partitionKey)}'";
|
||||
await foreach (var e in _tableClient.QueryAsync<TableEntity>(filter, cancellationToken: ct))
|
||||
{
|
||||
// Reverse-ticks RowKey → Azure's RowKey-ascending order IS newest-first.
|
||||
results.Add(Map(e));
|
||||
if (results.Count >= max) break;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ConfigBackupEntry?> TryGetAsync(
|
||||
string partitionKey, string backupId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _tableClient.GetEntityAsync<TableEntity>(partitionKey, backupId, cancellationToken: ct);
|
||||
return Map(response.Value);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> PruneAsync(string partitionKey, int keep, CancellationToken ct = default)
|
||||
{
|
||||
if (keep < 1) throw new ArgumentOutOfRangeException(nameof(keep));
|
||||
|
||||
var filter = $"PartitionKey eq '{Escape(partitionKey)}'";
|
||||
var seen = 0;
|
||||
var deleted = 0;
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(
|
||||
filter, select: new[] { "PartitionKey", "RowKey" }, cancellationToken: ct))
|
||||
{
|
||||
seen++;
|
||||
if (seen <= keep) continue;
|
||||
|
||||
try
|
||||
{
|
||||
await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, ETag.All, ct);
|
||||
deleted++;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Concurrent prune already removed it — idempotent.
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-ticks + short guid: newest sorts first, concurrent writers within the
|
||||
/// same tick cannot collide. The value is the public backupId.
|
||||
/// </summary>
|
||||
public static string BuildRowKey(DateTime utcNow)
|
||||
=> $"{DateTime.MaxValue.Ticks - utcNow.Ticks:D19}_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
private static string Escape(string s) => s.Replace("'", "''");
|
||||
|
||||
private static TableEntity Store(ConfigBackupEntry e) => new(e.PartitionKey, e.RowKey)
|
||||
{
|
||||
["TenantId"] = e.TenantId,
|
||||
["EntityJson"] = e.EntityJson,
|
||||
["ChangedBy"] = e.ChangedBy,
|
||||
["Source"] = e.Source,
|
||||
["Reason"] = e.Reason,
|
||||
["DiffJson"] = e.DiffJson,
|
||||
["BackupTakenAt"] = e.BackupTakenAt,
|
||||
};
|
||||
|
||||
private static ConfigBackupEntry Map(TableEntity e) => new()
|
||||
{
|
||||
PartitionKey = e.PartitionKey,
|
||||
RowKey = e.RowKey,
|
||||
TenantId = e.GetString("TenantId") ?? string.Empty,
|
||||
EntityJson = e.GetString("EntityJson") ?? string.Empty,
|
||||
ChangedBy = e.GetString("ChangedBy") ?? "system",
|
||||
Source = e.GetString("Source") ?? "unknown",
|
||||
Reason = e.GetString("Reason"),
|
||||
DiffJson = e.GetString("DiffJson"),
|
||||
BackupTakenAt = e.GetDateTime("BackupTakenAt") ?? default,
|
||||
};
|
||||
}
|
||||
}
|
||||
+225
-6
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -25,11 +27,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
private readonly TableClient _adminConfigTableClient;
|
||||
private readonly TableClient _previewWhitelistTableClient;
|
||||
private readonly TableClient _previewConfigTableClient;
|
||||
private readonly IConfigBackupRepository _backupRepo;
|
||||
private readonly ILogger<TableConfigRepository> _logger;
|
||||
|
||||
public TableConfigRepository(TableStorageService storage, ILogger<TableConfigRepository> logger)
|
||||
/// <summary>
|
||||
/// Changes to ONLY these properties do not snapshot the tenant-config row: the
|
||||
/// LastAuthClientId pair flips as an auth-flow side effect and would flood the
|
||||
/// two backup slots with states nobody ever wants to revert to.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> TenantBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LastUpdated", "UpdatedBy", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LastUpdated", "UpdatedBy",
|
||||
};
|
||||
|
||||
public TableConfigRepository(
|
||||
TableStorageService storage,
|
||||
IConfigBackupRepository backupRepo,
|
||||
ILogger<TableConfigRepository> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_backupRepo = backupRepo;
|
||||
_tenantConfigTableClient = storage.GetTableClient(Constants.TableNames.TenantConfiguration);
|
||||
_adminConfigTableClient = storage.GetTableClient(Constants.TableNames.AdminConfiguration);
|
||||
_previewWhitelistTableClient = storage.GetTableClient(Constants.TableNames.PreviewWhitelist);
|
||||
@@ -56,8 +78,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
|
||||
public Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
|
||||
=> SaveTenantConfigurationAsync(config, backupSource: null, backupReason: null);
|
||||
|
||||
public async Task<bool> SaveTenantConfigurationAsync(
|
||||
TenantConfiguration config, string? backupSource, string? backupReason)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
try
|
||||
{
|
||||
var entity = ConvertToTenantTableEntity(config);
|
||||
@@ -71,6 +102,123 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(TenantConfiguration Config, string ETag)?> GetTenantConfigurationWithEtagAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _tenantConfigTableClient.GetEntityAsync<TableEntity>(tenantId, "config");
|
||||
return (ConvertFromTenantTableEntity(entity.Value), entity.Value.ETag.ToString());
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// Everything else throws (fail-loud): the transactional caller must never treat a
|
||||
// storage outage as "no row".
|
||||
}
|
||||
|
||||
public async Task<bool> TryReplaceTenantConfigurationAsync(TenantConfiguration config, string etag)
|
||||
{
|
||||
var entity = ConvertToTenantTableEntity(config);
|
||||
try
|
||||
{
|
||||
await _tenantConfigTableClient.UpdateEntityAsync(entity, new ETag(etag), TableUpdateMode.Replace);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status is 412 or 404)
|
||||
{
|
||||
// 412: lost the CAS race — caller re-reads and retries (bounded).
|
||||
// 404: the row was deleted since the read (offboarding) — the retry's
|
||||
// re-read surfaces that as "no configuration row".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-write snapshot hook shared by the tenant- and admin-config save paths.
|
||||
/// Fail-SOFT by design: this is a safety net around long-standing writers
|
||||
/// (portal, plan, auth side effects) — a backup-storage hiccup must never turn
|
||||
/// into a config-save outage. The transactional patch/revert flow does its own
|
||||
/// fail-CLOSED snapshot before calling the conditional write and never relies
|
||||
/// on this hook.
|
||||
/// </summary>
|
||||
private async Task TrySnapshotBeforeSaveAsync<TModel>(
|
||||
TableClient tableClient,
|
||||
string partitionKey,
|
||||
string rowKey,
|
||||
Func<TableEntity, TModel> convertFromEntity,
|
||||
TModel incoming,
|
||||
HashSet<string> noiseProperties,
|
||||
string? changedBy,
|
||||
string? source,
|
||||
string? reason) where TModel : class
|
||||
{
|
||||
try
|
||||
{
|
||||
TableEntity existing;
|
||||
try
|
||||
{
|
||||
existing = (await tableClient.GetEntityAsync<TableEntity>(partitionKey, rowKey)).Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return; // Row creation — nothing to snapshot.
|
||||
}
|
||||
|
||||
// Compare at MODEL level, not raw-entity level: retrieved entities carry
|
||||
// DateTimeOffset/typing quirks that would false-positive against a freshly
|
||||
// built entity. The model round-trip puts both sides on identical CLR types.
|
||||
var before = convertFromEntity(existing);
|
||||
var changed = ConfigPropertyComparer.GetChangedPropertyNames(before, incoming);
|
||||
changed.ExceptWith(noiseProperties);
|
||||
if (changed.Count == 0)
|
||||
return; // Noise-only or no-op save — don't burn a backup slot.
|
||||
|
||||
await _backupRepo.UpsertAsync(BuildBackupEntry(
|
||||
existing, partitionKey, changedBy,
|
||||
writeSource: source ?? "unknown", reason: reason,
|
||||
diffJson: JsonSerializer.Serialize(ConfigDiffHelper.GetChanges(before, incoming))));
|
||||
|
||||
await _backupRepo.PruneAsync(partitionKey, Constants.ConfigBackupKeepCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Config backup snapshot failed for {PartitionKey} — proceeding with save (fail-soft)",
|
||||
partitionKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the raw stored row (same fidelity approach as the offboarding
|
||||
/// customs archive): every table property except the Azure pseudo-properties,
|
||||
/// serialized as JSON. Restore therefore survives model refactors.
|
||||
/// </summary>
|
||||
internal static ConfigBackupEntry BuildBackupEntry(
|
||||
TableEntity sourceRow, string partitionKey, string? changedBy,
|
||||
string writeSource, string? reason, string? diffJson)
|
||||
{
|
||||
var snapshot = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var kv in sourceRow)
|
||||
{
|
||||
if (kv.Key is "odata.etag" or "Timestamp") continue;
|
||||
snapshot[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
return new ConfigBackupEntry
|
||||
{
|
||||
PartitionKey = partitionKey,
|
||||
RowKey = TableConfigBackupRepository.BuildRowKey(DateTime.UtcNow),
|
||||
TenantId = partitionKey,
|
||||
EntityJson = JsonSerializer.Serialize(snapshot),
|
||||
ChangedBy = string.IsNullOrWhiteSpace(changedBy) ? "system" : changedBy,
|
||||
Source = writeSource,
|
||||
Reason = reason,
|
||||
DiffJson = diffJson,
|
||||
BackupTakenAt = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditional, single-property seed of ContactEmail (see <see cref="IConfigRepository"/>).
|
||||
/// Merge mode leaves every other stored property untouched, and the ETag precondition turns
|
||||
@@ -181,6 +329,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
public async Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_adminConfigTableClient, "GlobalConfig", "config",
|
||||
ConvertFromAdminTableEntity, config, AdminBackupNoiseProperties,
|
||||
config.UpdatedBy, "admin-config", reason: null);
|
||||
|
||||
try
|
||||
{
|
||||
var entity = ConvertToAdminTableEntity(config);
|
||||
@@ -226,13 +379,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
ApprovedBy = addedBy
|
||||
};
|
||||
|
||||
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
|
||||
// Conditional INSERT, not upsert: the storage layer arbitrates concurrent
|
||||
// activations (double signup → two auto-approve envelopes), so exactly one
|
||||
// caller sees "newly added" and runs the side effects (welcome mail, ops event).
|
||||
await _previewWhitelistTableClient.AddEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Throw, don't swallow: activation is the one step that must fail loud —
|
||||
// a false-y return here used to let callers send the welcome mail for a
|
||||
// tenant that was never actually activated.
|
||||
_logger.LogError(ex, "Error adding tenant {TenantId} to preview whitelist", tenantId);
|
||||
return false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +533,51 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
|
||||
}
|
||||
|
||||
// --- Welcome Email Sent Marker ---
|
||||
|
||||
public async Task<bool> TryMarkWelcomeEmailSentAsync(string tenantId)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, "welcome-email-sent")
|
||||
{
|
||||
{ "SentAt", DateTime.UtcNow }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Conditional INSERT: arbitrates the send race between the approval path and
|
||||
// the notification-email save path — exactly one caller wins and sends.
|
||||
await _previewWhitelistTableClient.AddEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error marking welcome email sent for tenant {TenantId}", tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearWelcomeEmailSentMarkerAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _previewWhitelistTableClient.DeleteEntityAsync(tenantId, "welcome-email-sent");
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// already gone
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-soft like the revoke path itself: a stale marker only suppresses a
|
||||
// courtesy mail on a later re-approve.
|
||||
_logger.LogWarning(ex, "Error clearing welcome email marker for tenant {TenantId}", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tenant Configuration Entity Mapping ---
|
||||
|
||||
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
|
||||
@@ -452,6 +660,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "UnrestrictedMode", config.UnrestrictedMode },
|
||||
{ "EntraAppRolesEnabled", config.EntraAppRolesEnabled },
|
||||
{ "OnboardedAt", config.OnboardedAt },
|
||||
{ "HomedAppClientId", config.HomedAppClientId },
|
||||
{ "LastAuthClientId", config.LastAuthClientId },
|
||||
{ "LastAuthClientIdSince", config.LastAuthClientIdSince },
|
||||
{ "PlanTier", config.PlanTier },
|
||||
{ "TrialExpiresUtc", config.TrialExpiresUtc },
|
||||
{ "TrialStartedUtc", config.TrialStartedUtc },
|
||||
@@ -557,6 +768,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false,
|
||||
EntraAppRolesEnabled = entity.GetBoolean("EntraAppRolesEnabled") ?? false,
|
||||
OnboardedAt = entity.GetDateTime("OnboardedAt"),
|
||||
// Null = legacy app registration (rows pre-dating the C4A8 app-reg move).
|
||||
HomedAppClientId = entity.GetString("HomedAppClientId"),
|
||||
LastAuthClientId = entity.GetString("LastAuthClientId"),
|
||||
LastAuthClientIdSince = entity.GetDateTime("LastAuthClientIdSince"),
|
||||
PlanTier = entity.GetString("PlanTier") ?? "free",
|
||||
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
|
||||
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
|
||||
@@ -578,7 +793,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
// --- Admin Configuration Entity Mapping ---
|
||||
|
||||
private TableEntity ConvertToAdminTableEntity(AdminConfiguration config)
|
||||
internal static TableEntity ConvertToAdminTableEntity(AdminConfiguration config)
|
||||
{
|
||||
var entity = new TableEntity("GlobalConfig", "config")
|
||||
{
|
||||
@@ -598,6 +813,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "NvdApiKey", config.NvdApiKey },
|
||||
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
|
||||
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
|
||||
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
|
||||
{ "SelfServiceAppHomingEnabled", config.SelfServiceAppHomingEnabled },
|
||||
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
|
||||
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
|
||||
{ "MsrcLastSyncUtc", config.MsrcLastSyncUtc },
|
||||
@@ -641,7 +858,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return entity;
|
||||
}
|
||||
|
||||
private AdminConfiguration ConvertFromAdminTableEntity(TableEntity entity)
|
||||
internal static AdminConfiguration ConvertFromAdminTableEntity(TableEntity entity)
|
||||
{
|
||||
return new AdminConfiguration
|
||||
{
|
||||
@@ -663,6 +880,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
NvdApiKey = entity.GetString("NvdApiKey"),
|
||||
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
|
||||
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
|
||||
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
|
||||
SelfServiceAppHomingEnabled = entity.GetBoolean("SelfServiceAppHomingEnabled") ?? false,
|
||||
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
|
||||
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
|
||||
MsrcLastSyncUtc = entity.GetString("MsrcLastSyncUtc"),
|
||||
|
||||
+60
-18
@@ -31,6 +31,9 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
private readonly IGraphFeatureDetector _graphFeatureDetector;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
|
||||
/// <summary>
|
||||
/// Known redirect URIs registered in the Entra ID app registration for the admin consent flow.
|
||||
@@ -49,14 +52,20 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
GraphTokenService graphTokenService,
|
||||
IGraphFeatureDetector graphFeatureDetector,
|
||||
TelemetryClient telemetryClient,
|
||||
OpsEventService opsEventService)
|
||||
OpsEventService opsEventService,
|
||||
EntraAppRegistry appRegistry,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
AppHomingService appHomingService)
|
||||
{
|
||||
_appHomingService = appHomingService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_graphTokenService = graphTokenService;
|
||||
_graphFeatureDetector = graphFeatureDetector;
|
||||
_telemetryClient = telemetryClient;
|
||||
_opsEventService = opsEventService;
|
||||
_appRegistry = appRegistry;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
[Function("GetAutopilotDeviceValidationConsentUrl")]
|
||||
@@ -67,7 +76,16 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Authentication enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var validatorClientId = _configuration["EntraId:ClientId"];
|
||||
// Dual app-reg window: by default the consent URL targets the app registration that
|
||||
// actually mints this tenant's Graph tokens. When the self-service homing funnel is on,
|
||||
// legacy-homed tenants are funneled to the PRIMARY app instead — the subsequent
|
||||
// consent-status / access-check verification then auto-flips their homing (re-consent,
|
||||
// verify, and flip collapse into the one flow the admin already runs).
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
var funnel = await _appHomingService.IsFunnelEligibleAsync(tenantConfig);
|
||||
var validatorClientId = funnel
|
||||
? _appRegistry.Primary.ClientId
|
||||
: _appRegistry.ResolveForTenant(tenantConfig).ClientId;
|
||||
if (string.IsNullOrWhiteSpace(validatorClientId))
|
||||
{
|
||||
var badConfig = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
@@ -114,14 +132,15 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
$"&state={Uri.EscapeDataString("autopilot-device-validation-enable")}";
|
||||
|
||||
_logger.LogInformation(
|
||||
"ConsentFlowStarted: tenant={TenantId} user={UserId} redirectUri={RedirectUri}",
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri);
|
||||
"ConsentFlowStarted: tenant={TenantId} user={UserId} redirectUri={RedirectUri} funnel={Funnel}",
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri, funnel);
|
||||
|
||||
_telemetryClient.TrackEvent("ConsentFlowStarted", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = requestCtx.TargetTenantId,
|
||||
["UserId"] = requestCtx.UserPrincipalName,
|
||||
["RedirectUri"] = redirectUri,
|
||||
["Funnel"] = funnel.ToString(),
|
||||
});
|
||||
|
||||
await _opsEventService.RecordConsentFlowStartedAsync(
|
||||
@@ -130,7 +149,8 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
consentUrl
|
||||
consentUrl,
|
||||
willAutoFlipHoming = funnel
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -143,6 +163,15 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Authentication enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
// Consent-driven homing auto-flip BEFORE the status read: after the funnel minted the
|
||||
// consent URL for the PRIMARY app, a still-legacy-homed tenant would resolve the status
|
||||
// against the legacy app and report "not consented" — aborting the frontend flow. Flipping
|
||||
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
|
||||
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
|
||||
// where the consent-propagation retry chain is exactly what we want to ride.
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
var flipOutcome = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
|
||||
|
||||
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
|
||||
|
||||
_telemetryClient.TrackEvent("ConsentStatusChecked", new Dictionary<string, string>
|
||||
@@ -157,7 +186,8 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
isConsented = result.IsConsented,
|
||||
message = result.Message
|
||||
message = result.Message,
|
||||
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -176,10 +206,13 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
/// opening both the UI badge AND the agent hard gate — without ever running the redirect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read-only: this endpoint never persists. It checks the SP token's <c>roles</c> claim (via
|
||||
/// <see cref="IGraphFeatureDetector"/>) rather than mere token-acquirability, so we never flip
|
||||
/// the gate "enabled" when the SP exists but the role was not actually consented (which would
|
||||
/// otherwise leave the agent stuck in an endless 503 at the real Graph call).
|
||||
/// It checks the SP token's <c>roles</c> claim (via <see cref="IGraphFeatureDetector"/>)
|
||||
/// rather than mere token-acquirability, so we never flip the gate "enabled" when the SP
|
||||
/// exists but the role was not actually consented (which would otherwise leave the agent
|
||||
/// stuck in an endless 503 at the real Graph call). The validation gate itself is never
|
||||
/// persisted here; the only write is the idempotent, flag-gated app-homing reconcile below
|
||||
/// (a tenant that consented the primary app gets its homing flipped — precedent: this
|
||||
/// endpoint already mutates the Graph caches on every click).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Function("GetAutopilotDeviceValidationAccessCheck")]
|
||||
@@ -190,18 +223,26 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Authentication + tenant-admin policy enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
// Always read fresh. This probe is admin-triggered (reconcile on consent-fail, the
|
||||
// "detect existing access" button, post-consent verification) and low-frequency, so a
|
||||
// stale cached snapshot must never decide it. In particular, a snapshot acquired during
|
||||
// AAD consent propagation can carry an empty roles claim and would otherwise be cached for
|
||||
// the token lifetime (~55 min) — wedging the admin out long after consent has propagated.
|
||||
// Invalidating here makes every click re-acquire from AAD, so a retry always reflects reality.
|
||||
_graphFeatureDetector.InvalidateTenant(requestCtx.TargetTenantId);
|
||||
|
||||
bool isTransient;
|
||||
bool accessPresent;
|
||||
AppHomingAutoFlipOutcome flipOutcome;
|
||||
using (var budgetCts = new CancellationTokenSource(AccessCheckBudget))
|
||||
{
|
||||
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
|
||||
// access" / reconcile path never touches consent-status, so this is its flip hook. A
|
||||
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
flipOutcome = await _appHomingService.TryAutoFlipToPrimaryAsync(
|
||||
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
|
||||
|
||||
// Always read fresh. This probe is admin-triggered (reconcile on consent-fail, the
|
||||
// "detect existing access" button, post-consent verification) and low-frequency, so a
|
||||
// stale cached snapshot must never decide it. In particular, a snapshot acquired during
|
||||
// AAD consent propagation can carry an empty roles claim and would otherwise be cached for
|
||||
// the token lifetime (~55 min) — wedging the admin out long after consent has propagated.
|
||||
// Invalidating here makes every click re-acquire from AAD, so a retry always reflects reality.
|
||||
_graphFeatureDetector.InvalidateTenant(requestCtx.TargetTenantId);
|
||||
|
||||
GraphPermissionSnapshot snapshot;
|
||||
try
|
||||
{
|
||||
@@ -230,6 +271,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
accessPresent,
|
||||
isTransient,
|
||||
requiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
|
||||
homingFlipped = flipOutcome == AppHomingAutoFlipOutcome.Flipped,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
+3
-3
@@ -68,11 +68,11 @@ public class DelegatedAdminManagementFunction
|
||||
if (validationError != null)
|
||||
return await Bad(req, validationError);
|
||||
|
||||
// Edition note: TARGET tenants may be any edition — an MSP on Enterprise may manage Community
|
||||
// customers. The Enterprise requirement applies to the delegated admin's HOME tenant and is
|
||||
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
|
||||
// customers. The Pro requirement applies to the delegated admin's HOME tenant and is
|
||||
// enforced at resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid). It cannot
|
||||
// be checked here: at grant time only the UPN is known, and UPN-domain → tenant mapping is not
|
||||
// reliable (multi-domain tenants). Grants to non-Enterprise-homed admins are simply inert.
|
||||
// reliable (multi-domain tenants). Grants to non-Pro-homed admins are simply inert.
|
||||
var entry = await _delegatedAdminService.UpsertAsync(
|
||||
body!.Upn, body.TenantId, role,
|
||||
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
|
||||
|
||||
@@ -120,8 +120,7 @@ public class TenantOffboardFunction
|
||||
// 2.b — Capture the Preview-Notification-Email before Phase 2.D wipes the
|
||||
// PreviewWhitelist table. Stored on the OffboardingHistory row (survives the wipe)
|
||||
// and consumed by the post-completion farewell-email side-effect in
|
||||
// TenantOffboardingHandler (currently disarmed by ResendEmailService's
|
||||
// OffboardFarewellEmailArmed gate). Fail-soft: null on lookup failure — the
|
||||
// TenantOffboardingHandler. Fail-soft: null on lookup failure — the
|
||||
// farewell email is a courtesy, not a correctness contract.
|
||||
var notificationEmail = await CaptureNotificationEmailAsync(normalizedTenantId);
|
||||
|
||||
@@ -615,7 +614,7 @@ public class TenantOffboardFunction
|
||||
var writtenAt = DateTime.UtcNow;
|
||||
tenantConfig.LastUpdated = writtenAt;
|
||||
|
||||
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig);
|
||||
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig, "offboard", "Disabled-gate before cascade");
|
||||
if (!saved)
|
||||
{
|
||||
// TableConfigRepository.SaveTenantConfigurationAsync returns false on transient
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !config.BootstrapTokenEnabled)
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
|
||||
if (!tenantExists || !config.BootstrapTokenEnabled)
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
{
|
||||
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
|
||||
}
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !config.BootstrapTokenEnabled)
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+2
-2
@@ -62,9 +62,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
return badReq;
|
||||
}
|
||||
|
||||
// Check if bootstrap token feature is enabled for this tenant
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
{
|
||||
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
// Feature gate — same generic message as not-found so a disabled tenant is
|
||||
// indistinguishable from an unknown code (no enumeration oracle).
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await ScriptResponseAsync(
|
||||
|
||||
+2
-2
@@ -41,9 +41,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var tenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Check if bootstrap token feature is enabled for this tenant
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
{
|
||||
// Return empty list instead of error — feature simply not visible
|
||||
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+2
-2
@@ -45,9 +45,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var tenantId = requestCtx.TargetTenantId;
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
// Check if bootstrap token feature is enabled for this tenant
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
{
|
||||
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
|
||||
|
||||
+2
-2
@@ -79,9 +79,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
return notFound;
|
||||
}
|
||||
|
||||
// Check if bootstrap token feature is enabled for the session's tenant
|
||||
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!tenantConfig.BootstrapTokenEnabled)
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
var disabled = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>POST /api/config/{tenantId}/app-homing</c> — the dedicated flip switch of the dual
|
||||
/// app-reg migration. Moves a tenant's <c>HomedAppClientId</c> between the legacy and primary
|
||||
/// app registrations by label (<c>"primary"</c> / <c>"legacy"</c> — never raw GUIDs, so a typo
|
||||
/// cannot route a tenant to a nonexistent app).
|
||||
/// <para>
|
||||
/// Tenant admins may flip TO primary only, without <c>force</c>, only while
|
||||
/// <see cref="AutopilotMonitor.Shared.Models.AdminConfiguration.SelfServiceAppHomingEnabled"/>
|
||||
/// is on, and only after the live consent probe proves the primary app is admin-consented in
|
||||
/// their tenant. Global Admins may flip both directions and may pass <c>force</c> to skip the
|
||||
/// probe (escape hatch — e.g. a tenant that consented delegated-only). The probe proves token
|
||||
/// acquirability, NOT the <c>DeviceManagementServiceConfig.Read.All</c> role: feature gates
|
||||
/// keep their own role-checked probes and simply evaluate the newly homed app after the flip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AppHomingFunction
|
||||
{
|
||||
private readonly ILogger<AppHomingFunction> _logger;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
|
||||
public AppHomingFunction(
|
||||
ILogger<AppHomingFunction> logger,
|
||||
AppHomingService appHomingService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
EntraAppRegistry appRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_appHomingService = appHomingService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_appRegistry = appRegistry;
|
||||
}
|
||||
|
||||
private sealed class AppHomingRequest
|
||||
{
|
||||
public string? Target { get; set; }
|
||||
public bool Force { get; set; }
|
||||
}
|
||||
|
||||
[Function("UpdateTenantAppHoming")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/app-homing")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
AppHomingRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<AppHomingRequest>(requestBody);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
var target = request?.Target?.Trim().ToLowerInvariant();
|
||||
if (target != "primary" && target != "legacy")
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, reason = "invalid-target", message = "Target must be \"primary\" or \"legacy\"." });
|
||||
return badRequest;
|
||||
}
|
||||
var targetPrimary = target == "primary";
|
||||
var force = request?.Force == true;
|
||||
|
||||
var config = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { success = false, reason = "tenant-not-found", message = "Tenant configuration not found." });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var currentlyPrimary = !_appRegistry.ResolveForTenant(config).IsLegacy;
|
||||
var selfServiceEnabled = requestCtx.IsGlobalAdmin
|
||||
|| await _adminConfigService.IsSelfServiceAppHomingEnabledAsync();
|
||||
|
||||
var decision = AppHomingService.EvaluateManualFlip(
|
||||
requestCtx.IsGlobalAdmin, selfServiceEnabled, _appRegistry.LegacyConfigured,
|
||||
currentlyPrimary, targetPrimary, force, probe: null);
|
||||
|
||||
AppHomingProbeResult? probe = null;
|
||||
if (decision.Kind == AppHomingDecisionKind.RequireProbe)
|
||||
{
|
||||
// Unbudgeted on purpose: the explicit flip click may ride the full consent
|
||||
// propagation retry chain (5+15+30 s) — the frontend shows a spinner.
|
||||
probe = await _appHomingService.ProbePrimaryConsentAsync(requestCtx.TargetTenantId);
|
||||
decision = AppHomingService.EvaluateManualFlip(
|
||||
requestCtx.IsGlobalAdmin, selfServiceEnabled, _appRegistry.LegacyConfigured,
|
||||
currentlyPrimary, targetPrimary, force, probe);
|
||||
}
|
||||
|
||||
if (decision.Kind == AppHomingDecisionKind.Deny)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"App-homing flip denied for tenant {TenantId} (target {Target}, force {Force}, by {User}): {Reason}",
|
||||
requestCtx.TargetTenantId, target, force, requestCtx.UserPrincipalName, decision.ReasonCode);
|
||||
var denied = req.CreateResponse((HttpStatusCode)decision.StatusCode);
|
||||
await denied.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
reason = decision.ReasonCode,
|
||||
probe = ProbePayload(probe),
|
||||
});
|
||||
return denied;
|
||||
}
|
||||
|
||||
var changed = decision.Kind == AppHomingDecisionKind.Allow;
|
||||
if (changed)
|
||||
{
|
||||
if (force)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FORCED app-homing flip for tenant {TenantId} to {Target} by {User} — consent probe skipped",
|
||||
requestCtx.TargetTenantId, target, requestCtx.UserPrincipalName);
|
||||
}
|
||||
await _appHomingService.FlipAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
targetPrimary ? _appRegistry.Primary.ClientId : null,
|
||||
requestCtx.UserPrincipalName,
|
||||
requestCtx.IsGlobalAdmin ? "manual-ga" : "manual-self-service",
|
||||
forced: force);
|
||||
config = await _tenantConfigService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
changed,
|
||||
homedApp = _appRegistry.ResolveForTenant(config).IsLegacy ? "legacy" : "primary",
|
||||
homedAppClientId = config.HomedAppClientId,
|
||||
lastAuthClientId = config.LastAuthClientId,
|
||||
lastAuthClientIdSince = config.LastAuthClientIdSince,
|
||||
probe = ProbePayload(probe),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating app homing for tenant {TenantId}", tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static object ProbePayload(AppHomingProbeResult? probe) => new
|
||||
{
|
||||
attempted = probe != null,
|
||||
succeeded = probe?.Succeeded ?? false,
|
||||
isTransient = probe?.IsTransient ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
|
||||
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
|
||||
SendTraceEvents = tenantConfig.SendTraceEvents,
|
||||
UnrestrictedMode = tenantConfig.UnrestrictedModeEnabled && tenantConfig.UnrestrictedMode,
|
||||
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow),
|
||||
GatherRules = gatherRules,
|
||||
ImeLogPatterns = imeLogPatterns,
|
||||
WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(),
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable schema of the tenant-configuration fields, for the MCP write surface:
|
||||
/// which fields exist, their JSON type/nullability, which are writable via
|
||||
/// PATCH config/{tenantId}/fields (and why not, when denied), the phase-2 GA-only markers,
|
||||
/// and which fields a revert preserves by default. Reflection-generated from the model and
|
||||
/// the patch service's deny-lists — cannot drift from what the patch endpoint enforces.
|
||||
/// Tenant-independent (no tenant data), but GlobalAdminOnly to match the write surface it
|
||||
/// describes. Route is literal — the catalog and ASP.NET routing both prefer literal
|
||||
/// segments over config/{tenantId} (same precedence config/all relies on).
|
||||
/// </summary>
|
||||
public class GetTenantConfigFieldsSchemaFunction
|
||||
{
|
||||
private readonly ILogger<GetTenantConfigFieldsSchemaFunction> _logger;
|
||||
|
||||
public GetTenantConfigFieldsSchemaFunction(ILogger<GetTenantConfigFieldsSchemaFunction> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("GetTenantConfigFieldsSchema")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/fields-schema")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
count = schema.Count,
|
||||
writableCount = schema.Count(f => f.Writable),
|
||||
fields = schema,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error building tenant-config fields schema");
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// even when they happen to be an admin of their own home tenant. This also prevents a
|
||||
// "***REDACTED***" placeholder from ever reaching the Settings save round-trip for an
|
||||
// own-tenant admin.
|
||||
if (!CanViewSecrets(requestCtx))
|
||||
if (ShouldRedact(requestCtx, req.Url.Query))
|
||||
config = config.RedactedCopyForReader();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -81,5 +81,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
&& string.Equals(ctx.TargetTenantId, ctx.TenantId, StringComparison.OrdinalIgnoreCase);
|
||||
return ctx.IsGlobalAdmin || ownTenantAdminView;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redaction decision including the ?view=redacted opt-in, which forces the redacted
|
||||
/// copy even for callers WITH secret clearance. Exists for the MCP get_tenant_config
|
||||
/// tool: a Global Admin's MCP session must never pull clear-text webhook/SAS secrets
|
||||
/// into model context. Pure static for unit-testing without an HttpRequestData mock.
|
||||
/// </summary>
|
||||
internal static bool ShouldRedact(RequestContext ctx, string query)
|
||||
{
|
||||
var forceRedacted = string.Equals(
|
||||
System.Web.HttpUtility.ParseQueryString(query)["view"],
|
||||
"redacted", StringComparison.OrdinalIgnoreCase);
|
||||
return forceRedacted || !CanViewSecrets(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-10
@@ -15,13 +15,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
|
||||
public GetTenantFeatureFlagsFunction(
|
||||
ILogger<GetTenantFeatureFlagsFunction> logger,
|
||||
TenantConfigurationService configService)
|
||||
TenantConfigurationService configService,
|
||||
AppHomingService appHomingService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_appHomingService = appHomingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -46,9 +49,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -66,16 +70,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc)
|
||||
internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
|
||||
{
|
||||
var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
var entitlements = FeatureEntitlementCatalog.Get(edition);
|
||||
var isTrial = edition == TenantEdition.Enterprise &&
|
||||
!string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase);
|
||||
var isTrial = edition == TenantEdition.Pro &&
|
||||
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||
|
||||
return new
|
||||
{
|
||||
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
|
||||
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
|
||||
// Community enable) — field name kept for web compatibility.
|
||||
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
|
||||
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
|
||||
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
|
||||
// read the full config, so this boolean is their only signal. Deliberately exposes no
|
||||
@@ -87,21 +93,30 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
// Dual app-reg self-service migration: when true, running the consent flow (or
|
||||
// "Detect existing access") targets the NEW app registration and auto-flips this
|
||||
// tenant's homing after verification — drives the explanatory banner in the
|
||||
// Autopilot Validation settings section. Non-sensitive: exposes no client ids.
|
||||
appHomingFunnelActive,
|
||||
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
||||
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
||||
showScriptOutput = config.ShowScriptOutput ?? true,
|
||||
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||
// Gather-rules page validation indicator. UnrestrictedMode itself is just a display
|
||||
// hint — the privileged toggle is UnrestrictedModeEnabled (admin-only, stays in
|
||||
// the full config response).
|
||||
unrestrictedMode = config.UnrestrictedMode,
|
||||
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
|
||||
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
|
||||
// (admin-only, stays in the full config response).
|
||||
unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
|
||||
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
|
||||
// drives the EditionBadge, trial CTA and retention hint in the web UI.
|
||||
edition = edition.ToString().ToLowerInvariant(),
|
||||
isTrial,
|
||||
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
||||
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
||||
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
|
||||
// "set a contact address" banner for Pro tenants. Boolean only — the address
|
||||
// itself stays in the admin-gated full config response.
|
||||
contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = entitlements.RetentionCapDays,
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists a tenant's pre-write config snapshots — METADATA ONLY. The stored EntityJson
|
||||
/// holds the raw row including clear-text secrets (webhook URLs, SAS) and is NEVER
|
||||
/// returned here: responses reach model context via MCP. The masked DiffJson is the
|
||||
/// only change summary exposed.
|
||||
/// </summary>
|
||||
public class ListTenantConfigBackupsFunction
|
||||
{
|
||||
private const int MaxListSize = 25;
|
||||
|
||||
private readonly ILogger<ListTenantConfigBackupsFunction> _logger;
|
||||
private readonly IConfigBackupRepository _backupRepo;
|
||||
|
||||
public ListTenantConfigBackupsFunction(
|
||||
ILogger<ListTenantConfigBackupsFunction> logger,
|
||||
IConfigBackupRepository backupRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_backupRepo = backupRepo;
|
||||
}
|
||||
|
||||
[Function("ListTenantConfigBackups")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/backups")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var max = int.TryParse(query["max"], out var parsed)
|
||||
? Math.Clamp(parsed, 1, MaxListSize)
|
||||
: MaxListSize;
|
||||
|
||||
var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId = requestCtx.TargetTenantId,
|
||||
backups = backups.Select(b => new
|
||||
{
|
||||
backupId = b.RowKey,
|
||||
backupTakenAt = b.BackupTakenAt,
|
||||
changedBy = b.ChangedBy,
|
||||
source = b.Source,
|
||||
reason = b.Reason,
|
||||
diff = TryParseDiff(b.DiffJson),
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing config backups for tenant {TenantId}", tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string>? TryParseDiff(string? diffJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(diffJson)) return null;
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(diffJson);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null; // advisory only — a malformed stored diff must not break the listing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Transactional field-level patch of a tenant's configuration (GlobalAdminOnly for now;
|
||||
/// phase 2 widens the policy line + caller tier). Unlike the full-model PUT, this takes
|
||||
/// ONLY the fields to change, and the service verifies after the conditional write that
|
||||
/// exactly those fields changed — rolling back automatically on drift. Every write is
|
||||
/// preceded by a fail-closed snapshot into ConfigurationBackups (revertible via
|
||||
/// POST config/{tenantId}/revert).
|
||||
/// </summary>
|
||||
public class PatchTenantConfigurationFieldsFunction
|
||||
{
|
||||
internal const int MaxBodyBytes = 65_536;
|
||||
|
||||
private readonly ILogger<PatchTenantConfigurationFieldsFunction> _logger;
|
||||
private readonly TenantConfigPatchService _patchService;
|
||||
|
||||
public PatchTenantConfigurationFieldsFunction(
|
||||
ILogger<PatchTenantConfigurationFieldsFunction> logger,
|
||||
TenantConfigPatchService patchService)
|
||||
{
|
||||
_logger = logger;
|
||||
_patchService = patchService;
|
||||
}
|
||||
|
||||
public sealed class PatchFieldsRequest
|
||||
{
|
||||
public JObject? Fields { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
[Function("PatchTenantConfigurationFields")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/fields")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > MaxBodyBytes)
|
||||
{
|
||||
return await WriteError(req, HttpStatusCode.BadRequest, "Request body too large");
|
||||
}
|
||||
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
PatchFieldsRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<PatchFieldsRequest>(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await WriteError(req, HttpStatusCode.BadRequest, "Invalid JSON body");
|
||||
}
|
||||
if (request?.Fields == null || !request.Fields.Properties().Any())
|
||||
{
|
||||
return await WriteError(req, HttpStatusCode.BadRequest,
|
||||
"Body must be { \"fields\": { <fieldName>: <value>, ... }, \"reason\": \"...\" } with at least one field.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"PatchTenantConfigurationFields: {TenantId} by {User} ({FieldCount} fields)",
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, request.Fields.Count);
|
||||
|
||||
var outcome = await _patchService.ApplyFieldPatchAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
request.Fields,
|
||||
requestCtx.UserPrincipalName,
|
||||
ResolveSource(req, "patch"),
|
||||
request.Reason,
|
||||
TenantConfigCallerTier.GlobalAdmin);
|
||||
|
||||
return await WriteOutcome(req, outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error patching configuration for tenant {TenantId}", tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tags the backup Source with the write path (MCP tools stamp X-Client-Source: mcp).</summary>
|
||||
internal static string ResolveSource(HttpRequestData req, string operation)
|
||||
=> req.Headers.TryGetValues("X-Client-Source", out var values)
|
||||
&& string.Equals(values.FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase)
|
||||
? $"mcp-{operation}"
|
||||
: $"api-{operation}";
|
||||
|
||||
internal static async Task<HttpResponseData> WriteOutcome(HttpRequestData req, PatchOutcome outcome)
|
||||
{
|
||||
if (outcome.Success)
|
||||
{
|
||||
var ok = req.CreateResponse(HttpStatusCode.OK);
|
||||
await ok.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
appliedFields = outcome.AppliedFields,
|
||||
diff = outcome.MaskedDiff,
|
||||
backupId = outcome.BackupId,
|
||||
noOp = outcome.AppliedFields.Count == 0,
|
||||
});
|
||||
return ok;
|
||||
}
|
||||
|
||||
var status = outcome.Failure switch
|
||||
{
|
||||
PatchFailure.NotFound => HttpStatusCode.NotFound,
|
||||
PatchFailure.InvalidField => HttpStatusCode.BadRequest,
|
||||
PatchFailure.ValidationFailed => HttpStatusCode.BadRequest,
|
||||
PatchFailure.BackupFailed => HttpStatusCode.ServiceUnavailable,
|
||||
PatchFailure.WriteConflict => HttpStatusCode.Conflict,
|
||||
// Drift means the write was undone (or needs manual attention) — surface as 500:
|
||||
// the caller changed nothing durable and must involve an operator.
|
||||
_ => HttpStatusCode.InternalServerError,
|
||||
};
|
||||
var error = req.CreateResponse(status);
|
||||
await error.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = outcome.Error,
|
||||
backupId = outcome.BackupId,
|
||||
drift = outcome.Drift,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteError(HttpRequestData req, HttpStatusCode status, string message)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
await response.WriteAsJsonAsync(new { success = false, message });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -54,7 +55,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
|
||||
/// Body: { "planTier"?: "community"|"enterprise", "trialExpiresUtc"?: ISO-8601 | null }.
|
||||
/// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null }.
|
||||
/// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer
|
||||
/// accepted on writes.
|
||||
/// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA
|
||||
/// re-grants stay possible); explicit null ends the trial. Absent properties are unchanged.
|
||||
/// </summary>
|
||||
@@ -96,10 +99,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
|
||||
if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
|
||||
newPlanTier != FeatureEntitlementCatalog.EnterpriseTierName)
|
||||
newPlanTier != FeatureEntitlementCatalog.ProTierName)
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.EnterpriseTierName}");
|
||||
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.ProTierName}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +162,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Fail-loud: SaveConfigurationAsync throws when the write did not persist (and
|
||||
// invalidates the cached — possibly mutated — instance in its finally), so a
|
||||
// failed save can never be audited or returned as 200.
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "plan tier change");
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -194,9 +197,39 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Enterprise trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Enterprise.
|
||||
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Pro.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
/// contact-address prompt — asking for an address would be pointless there. The contact
|
||||
/// requirement is enforced ONLY at this plan entry point, never as a runtime gate on Pro
|
||||
/// features (existing Pro tenants get the dashboard banner instead of a lockout); GA
|
||||
/// plan assignment (PATCH plan) deliberately has no such block — the admin UI warns.
|
||||
/// </summary>
|
||||
internal static (string Error, string Message)? EvaluateTrialStart(TenantConfiguration config, DateTime nowUtc)
|
||||
{
|
||||
if (config.TrialConsumed)
|
||||
{
|
||||
return ("TrialAlreadyConsumed",
|
||||
"This tenant has already used its one self-service Pro trial. Contact support to extend.");
|
||||
}
|
||||
|
||||
if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Pro)
|
||||
{
|
||||
return ("AlreadyPro", "This tenant is already on the Pro plan.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.ContactEmail))
|
||||
{
|
||||
return ("ContactEmailRequired",
|
||||
"Pro requires a tenant contact address so we can reach you about service or security matters. Set it under Settings → Tenant → Contact, then start the trial.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[Function("StartTenantTrial")]
|
||||
public async Task<HttpResponseData> StartTrial(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
||||
@@ -218,25 +251,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
|
||||
if (config.TrialConsumed)
|
||||
if (EvaluateTrialStart(config, nowUtc) is { } deny)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "TrialAlreadyConsumed",
|
||||
message = "This tenant has already used its one self-service Enterprise trial. Contact support to extend."
|
||||
});
|
||||
return conflict;
|
||||
}
|
||||
|
||||
if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Enterprise)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "AlreadyEnterprise",
|
||||
message = "This tenant is already on the Enterprise edition."
|
||||
});
|
||||
await conflict.WriteAsJsonAsync(new { error = deny.Error, message = deny.Message });
|
||||
return conflict;
|
||||
}
|
||||
|
||||
@@ -247,7 +265,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start");
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -267,7 +285,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
tenantId = requestCtx.TargetTenantId,
|
||||
trialStartedUtc = config.TrialStartedUtc,
|
||||
trialExpiresUtc = config.TrialExpiresUtc,
|
||||
effectiveEdition = FeatureEntitlementCatalog.EnterpriseTierName
|
||||
effectiveEdition = FeatureEntitlementCatalog.ProTierName
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Restores a tenant's configuration from a ConfigurationBackups snapshot (latest by
|
||||
/// default). The revert itself snapshots the current state first — so a revert is
|
||||
/// always revertible — and runs through the same CAS-write + exactly-these-fields
|
||||
/// verification as the field patch. Protected/system-owned fields (plan/trial,
|
||||
/// HomedAppClientId, auth provenance) stay at their CURRENT values unless
|
||||
/// includeProtectedFields is explicitly set.
|
||||
/// </summary>
|
||||
public class RevertTenantConfigurationFunction
|
||||
{
|
||||
private readonly ILogger<RevertTenantConfigurationFunction> _logger;
|
||||
private readonly TenantConfigPatchService _patchService;
|
||||
|
||||
public RevertTenantConfigurationFunction(
|
||||
ILogger<RevertTenantConfigurationFunction> logger,
|
||||
TenantConfigPatchService patchService)
|
||||
{
|
||||
_logger = logger;
|
||||
_patchService = patchService;
|
||||
}
|
||||
|
||||
public sealed class RevertRequest
|
||||
{
|
||||
public string? BackupId { get; set; }
|
||||
public bool IncludeProtectedFields { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
[Function("RevertTenantConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/revert")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
RevertRequest? request;
|
||||
try
|
||||
{
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
request = string.IsNullOrWhiteSpace(body)
|
||||
? new RevertRequest()
|
||||
: JsonConvert.DeserializeObject<RevertRequest>(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid JSON body" });
|
||||
return badRequest;
|
||||
}
|
||||
request ??= new RevertRequest();
|
||||
|
||||
_logger.LogWarning(
|
||||
"RevertTenantConfiguration: {TenantId} by {User} (backupId={BackupId}, includeProtectedFields={IncludeProtected})",
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName,
|
||||
request.BackupId ?? "(latest)", request.IncludeProtectedFields);
|
||||
|
||||
var outcome = await _patchService.RevertAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
request.BackupId,
|
||||
request.IncludeProtectedFields,
|
||||
requestCtx.UserPrincipalName,
|
||||
PatchTenantConfigurationFieldsFunction.ResolveSource(req, "revert"),
|
||||
request.Reason,
|
||||
TenantConfigCallerTier.GlobalAdmin);
|
||||
|
||||
return await PatchTenantConfigurationFieldsFunction.WriteOutcome(req, outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reverting configuration for tenant {TenantId}", tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-259
@@ -63,28 +63,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Per-tenant rate-limit overrides are optional (null = inherit global), but if provided
|
||||
// they must be positive — a zero/negative override would throttle every request.
|
||||
var customLimitError =
|
||||
config.CustomRateLimitRequestsPerMinute is int dev && dev < 1 ? "Device API Rate Limit" :
|
||||
config.CustomUserRateLimitRequestsPerMinute is int usr && usr < 1 ? "User API Rate Limit" :
|
||||
null;
|
||||
if (customLimitError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default)." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// The contact address is where enforcement actions and service notices are sent,
|
||||
// so it must be an address rather than whatever string a direct API caller posts.
|
||||
var contactEmailError = ValidateContactEmail(config.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid contact email: {contactEmailError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Normalize so the stored value never carries surrounding whitespace, and an
|
||||
// all-whitespace submission clears the field instead of masquerading as a value.
|
||||
config.ContactEmail = string.IsNullOrWhiteSpace(config.ContactEmail) ? null : config.ContactEmail.Trim();
|
||||
@@ -99,54 +77,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// admins are served the FULL config so this is normally a no-op for them.)
|
||||
config.RestoreRedactedSecretsFrom(existingConfig);
|
||||
|
||||
// Validate webhook URLs (SSRF protection)
|
||||
var webhookUrlError = SsrfGuard.ValidateWebhookUrlFormat(config.WebhookUrl);
|
||||
if (webhookUrlError != null)
|
||||
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
|
||||
// headers, notification channels, diagnostics SAS, retention cap) — single source
|
||||
// with the transactional field-patch flow (TenantConfigValidation).
|
||||
var validationError = TenantConfigValidation.ValidateModel(config, existingConfig, requestCtx.IsGlobalAdmin);
|
||||
if (validationError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Webhook URL: {webhookUrlError}" });
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = validationError });
|
||||
return badRequest;
|
||||
}
|
||||
var teamsUrlError = SsrfGuard.ValidateWebhookUrlFormat(config.TeamsWebhookUrl);
|
||||
if (teamsUrlError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Teams Webhook URL: {teamsUrlError}" });
|
||||
return badRequest;
|
||||
}
|
||||
var headersError = ValidateWebhookCustomHeaders(config.WebhookCustomHeadersJson);
|
||||
if (headersError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid custom headers: {headersError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Per-channel counterpart of the two checks above: every channel's URL and custom
|
||||
// headers must pass the same format/SSRF gates as the legacy single-webhook fields.
|
||||
var channelsError = ValidateNotificationChannels(config.NotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid notification channels: {channelsError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually
|
||||
// selected the CustomerSas destination. In Hosted mode the field is unused at
|
||||
// runtime (see GetDiagnosticsUploadUrlFunction), so a stale/legacy value left
|
||||
// over from a prior CustomerSas configuration must not block a Hosted save.
|
||||
// Mirrors the runtime branching in GetDiagnosticsUploadUrlFunction.Run.
|
||||
var diagDestination = AutopilotMonitor.Functions.Functions.Diagnostics.GetDiagnosticsUploadUrlFunction
|
||||
.NormalizeDestination(config.DiagnosticsUploadDestination);
|
||||
if (diagDestination == AutopilotMonitor.Functions.Functions.Diagnostics.GetDiagnosticsUploadUrlFunction.DestinationCustomerSas)
|
||||
{
|
||||
var diagSasError = SsrfGuard.ValidateAzureBlobSasUrlFormat(config.DiagnosticsBlobSasUrl);
|
||||
if (diagSasError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Diagnostics SAS URL: {diagSasError}" });
|
||||
return badRequest;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure tenant ID matches
|
||||
config.TenantId = requestCtx.TargetTenantId;
|
||||
@@ -188,6 +128,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.ValidateDeviceAssociation = existingConfig.ValidateDeviceAssociation;
|
||||
}
|
||||
|
||||
// App-reg homing is IMMUTABLE via the generic PUT — for ALL callers, GA included.
|
||||
// The only writers are the consent-driven auto-flip and POST config/{tenantId}/
|
||||
// app-homing (AppHomingFunction). Prod incident 2026-07-31 18:03Z: the consent
|
||||
// auto-flip landed, and 600ms later the frontend's routine "persist validation
|
||||
// toggle" PUT — round-tripping a config loaded BEFORE the flip — silently reverted
|
||||
// the homing to legacy, because a GA caller's stale null was indistinguishable
|
||||
// from an intentional revert. A full-model PUT can never carry that intent.
|
||||
config.HomedAppClientId = existingConfig.HomedAppClientId;
|
||||
|
||||
// Last-seen auth provenance is system-written (AuthFunction) — never via PUT,
|
||||
// for ANY caller: a round-tripped stale view must not rewind the observability.
|
||||
config.LastAuthClientId = existingConfig.LastAuthClientId;
|
||||
config.LastAuthClientIdSince = existingConfig.LastAuthClientIdSince;
|
||||
|
||||
// OnboardedBy is system-written once at first login (AuthFunction) and immutable
|
||||
// after that — never via PUT, for ANY caller: a revoke → re-approve cycle
|
||||
// auto-promotes whatever UPN is stored here, so a client-supplied value could
|
||||
// smuggle in an arbitrary promotable user (or null out the audit provenance).
|
||||
config.OnboardedBy = existingConfig.OnboardedBy;
|
||||
|
||||
// Safety: if GA gate is off, force UnrestrictedMode to false
|
||||
if (!config.UnrestrictedModeEnabled)
|
||||
{
|
||||
@@ -207,30 +167,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.TrialConsumed = existingConfig.TrialConsumed;
|
||||
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
|
||||
|
||||
// Retention cap (edition entitlement): non-GA callers may only set 7..cap days.
|
||||
// Enforced only when the caller actually CHANGED the value, so a tenant whose
|
||||
// stored value predates the cap (e.g. 180 on Community) can still save unrelated
|
||||
// settings. 0 (= infinite) is a GA-only escape hatch. Edition resolves from the
|
||||
// STORED config — the client-sent plan fields were just discarded above.
|
||||
if (!requestCtx.IsGlobalAdmin && config.DataRetentionDays != existingConfig.DataRetentionDays)
|
||||
{
|
||||
var cap = FeatureEntitlementCatalog
|
||||
.Get(TenantEntitlementService.ResolveEdition(existingConfig, DateTime.UtcNow))
|
||||
.RetentionCapDays;
|
||||
if (config.DataRetentionDays < 7 || config.DataRetentionDays > cap)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Enterprise for up to 365 days."
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
// Save configuration (retention cap already enforced by ValidateModel above)
|
||||
await _configService.SaveConfigurationAsync(config, "portal-put", null);
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
@@ -260,180 +198,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxCustomHeadersJsonLength = 8192;
|
||||
private const int MaxCustomHeaderCount = 25;
|
||||
private const int MaxNotificationChannelsJsonLength = 65536;
|
||||
// Forwarding shims — the implementations moved to TenantConfigValidation (shared with
|
||||
// the transactional field-patch flow). Kept so existing callers and the validator test
|
||||
// suites keep their call sites; new code should reference TenantConfigValidation.
|
||||
internal const int MaxContactEmailLength = TenantConfigValidation.MaxContactEmailLength;
|
||||
|
||||
// RFC 5321 caps a forward path at 254 characters.
|
||||
internal const int MaxContactEmailLength = 254;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the tenant contact address. Returns an error message, or null when valid/empty.
|
||||
/// Empty is legitimate — it means we have no way to reach the tenant.
|
||||
/// <para>
|
||||
/// Deliberately not an RFC 5322 parser: the job is to reject values that are not addresses
|
||||
/// at all. Specifically it rejects recipient lists (a comma would silently widen who receives
|
||||
/// service notices), display-name forms, and control characters (which would let a caller
|
||||
/// forge mail headers once this address is actually mailed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return null;
|
||||
=> TenantConfigValidation.ValidateContactEmail(email);
|
||||
|
||||
var trimmed = email.Trim();
|
||||
|
||||
if (trimmed.Length > MaxContactEmailLength)
|
||||
return $"must be at most {MaxContactEmailLength} characters.";
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "must not contain control characters.";
|
||||
if (char.IsWhiteSpace(ch) || ch == ',' || ch == ';' || ch == '<' || ch == '>')
|
||||
return "must be a single address, without spaces, separators or angle brackets.";
|
||||
}
|
||||
|
||||
var at = trimmed.IndexOf('@');
|
||||
if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1)
|
||||
return "must contain a single \"@\" with text on both sides.";
|
||||
|
||||
// A bare host with no dot is unreachable from our sender, so it is a typo, not an address.
|
||||
var domain = trimmed.Substring(at + 1);
|
||||
if (!domain.Contains('.') || domain.StartsWith(".", StringComparison.Ordinal)
|
||||
|| domain.EndsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return "the domain part must be a dotted host name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the notification-channel list JSON. Returns an error message, or null when
|
||||
/// valid/empty. Strict counterpart of the fail-soft <c>NotificationChannel.ParseList</c>:
|
||||
/// entries the parser would silently drop (missing id, unknown provider) are rejected here
|
||||
/// so a tenant admin gets feedback instead of a channel that never fires. Each channel's
|
||||
/// URL and custom headers pass the same gates as the legacy single-webhook fields.
|
||||
/// </summary>
|
||||
internal static string? ValidateNotificationChannels(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
=> TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
|
||||
if (json.Length > MaxNotificationChannelsJsonLength)
|
||||
return $"too large (max {MaxNotificationChannelsJsonLength} characters).";
|
||||
|
||||
List<Shared.Models.Notifications.NotificationChannel>? channels;
|
||||
try
|
||||
{
|
||||
channels = System.Text.Json.JsonSerializer.Deserialize<List<Shared.Models.Notifications.NotificationChannel>>(
|
||||
json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return "not valid JSON.";
|
||||
}
|
||||
|
||||
if (channels == null)
|
||||
return "must be a JSON array of channels.";
|
||||
|
||||
if (channels.Count > Shared.Models.Notifications.NotificationChannel.MaxChannelsPerTenant)
|
||||
return $"too many channels (max {Shared.Models.Notifications.NotificationChannel.MaxChannelsPerTenant}).";
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (channel == null || string.IsNullOrWhiteSpace(channel.Id))
|
||||
return "every channel needs an id.";
|
||||
if (!ids.Add(channel.Id))
|
||||
return $"duplicate channel id \"{channel.Id}\".";
|
||||
|
||||
var label = string.IsNullOrWhiteSpace(channel.Name) ? channel.Id : channel.Name;
|
||||
|
||||
if (!Enum.IsDefined(typeof(Shared.Models.Notifications.WebhookProviderType), channel.ProviderType)
|
||||
|| channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.None)
|
||||
return $"channel \"{label}\" has an invalid provider type.";
|
||||
|
||||
var urlError = SsrfGuard.ValidateWebhookUrlFormat(channel.Url);
|
||||
if (urlError != null)
|
||||
return $"channel \"{label}\": {urlError}";
|
||||
|
||||
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
|
||||
if (headerError != null)
|
||||
return $"channel \"{label}\" headers: {headerError}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when
|
||||
/// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF
|
||||
/// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected
|
||||
/// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders().
|
||||
/// </summary>
|
||||
internal static string? ValidateWebhookCustomHeaders(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
if (json.Length > MaxCustomHeadersJsonLength)
|
||||
return $"too large (max {MaxCustomHeadersJsonLength} characters).";
|
||||
|
||||
System.Text.Json.JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return "not valid JSON.";
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return "must be a JSON object of header name/value pairs.";
|
||||
|
||||
var count = 0;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (++count > MaxCustomHeaderCount)
|
||||
return $"too many headers (max {MaxCustomHeaderCount}).";
|
||||
|
||||
if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
return $"header \"{prop.Name}\" must have a string value.";
|
||||
|
||||
if (!IsValidHeaderName(prop.Name))
|
||||
return $"\"{prop.Name}\" is not a valid HTTP header name.";
|
||||
|
||||
var value = prop.Value.GetString();
|
||||
if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0))
|
||||
return $"value for \"{prop.Name}\" must not contain line breaks.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators).</summary>
|
||||
private static bool IsValidHeaderName(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
foreach (var ch in name)
|
||||
{
|
||||
var isTokenChar =
|
||||
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
|
||||
"!#$%&'*+-.^_`|~".IndexOf(ch) >= 0;
|
||||
if (!isTokenChar)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
=> TenantConfigValidation.ValidateWebhookCustomHeaders(json);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -42,17 +42,23 @@ public class GetGraphPermissionsStatusFunction
|
||||
private readonly IGraphFeatureDetector _detector;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TelemetryClient _telemetry;
|
||||
private readonly Security.EntraAppRegistry _appRegistry;
|
||||
private readonly Services.TenantConfigurationService _tenantConfigService;
|
||||
|
||||
public GetGraphPermissionsStatusFunction(
|
||||
ILogger<GetGraphPermissionsStatusFunction> logger,
|
||||
IGraphFeatureDetector detector,
|
||||
IConfiguration configuration,
|
||||
TelemetryClient telemetry)
|
||||
TelemetryClient telemetry,
|
||||
Security.EntraAppRegistry appRegistry,
|
||||
Services.TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_detector = detector;
|
||||
_configuration = configuration;
|
||||
_telemetry = telemetry;
|
||||
_appRegistry = appRegistry;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
[Function("GetGraphPermissionsStatus")]
|
||||
@@ -87,9 +93,14 @@ public class GetGraphPermissionsStatusFunction
|
||||
|
||||
EmitStatusChecked(requestCtx.TargetTenantId, requestCtx.UserPrincipalName, snapshot);
|
||||
|
||||
// Dual app-reg window: the surfaced client id pre-fills the customer's grant-script
|
||||
// one-liner, so it must be the app that actually acts for this tenant (its homed app).
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
var homedClientId = _appRegistry.ResolveForTenant(tenantConfig).ClientId;
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
clientId = _configuration["EntraId:ClientId"] ?? string.Empty,
|
||||
clientId = homedClientId ?? string.Empty,
|
||||
isTransient = snapshot.IsTransient,
|
||||
grantedRoles = snapshot.GrantedRoles.ToArray(),
|
||||
features,
|
||||
|
||||
@@ -29,6 +29,8 @@ public class AuthFunction
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -40,7 +42,9 @@ public class AuthFunction
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
McpUserService mcpUserService)
|
||||
McpUserService mcpUserService,
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
EntraAppRegistry appRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_globalAdminService = globalAdminService;
|
||||
@@ -52,6 +56,8 @@ public class AuthFunction
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_mcpUserService = mcpUserService;
|
||||
_tenantAutoApproveEnqueuer = tenantAutoApproveEnqueuer;
|
||||
_appRegistry = appRegistry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,7 +100,7 @@ public class AuthFunction
|
||||
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
|
||||
var tenantConfigTask = _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(upn);
|
||||
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Enterprise-only seat).
|
||||
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Pro-only seat).
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn, tenantId);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
|
||||
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
@@ -132,10 +138,14 @@ public class AuthFunction
|
||||
// still onboards normally; the handlers self-gate when the config already exists.
|
||||
var isDelegated = delegatedTenantIds.Count > 0;
|
||||
var isHomeTenantParticipant = !isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null;
|
||||
// Which app registration minted this login's token (dual app-reg window) — drives the
|
||||
// onboarding homing decision and the per-tenant last-seen provenance below.
|
||||
var tokenAudience = principal.GetAudience();
|
||||
if (isHomeTenantParticipant)
|
||||
{
|
||||
await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn);
|
||||
await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn, tokenAudience);
|
||||
await HandleAutoReEnableAsync(tenantConfig, tenantId);
|
||||
await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience);
|
||||
}
|
||||
|
||||
// --- Pure decision logic (tested by AuthFunctionTests) ---
|
||||
@@ -143,7 +153,8 @@ public class AuthFunction
|
||||
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
|
||||
memberRole, mcpCheck, existingAdmins.Count > 0,
|
||||
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds);
|
||||
delegatedTenantIds,
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary");
|
||||
|
||||
if (!decision.IsSuccess)
|
||||
{
|
||||
@@ -155,8 +166,8 @@ public class AuthFunction
|
||||
|
||||
if (errorValue == "TenantSuspended")
|
||||
_logger.LogWarning("Login attempt for suspended tenant: {TenantId} by user {Upn}", tenantId, upn);
|
||||
else if (errorValue == "PrivatePreview")
|
||||
_logger.LogInformation("Tenant {TenantId} blocked by preview gate (user: {Upn})", tenantId, upn);
|
||||
else if (errorValue == "PendingActivation")
|
||||
_logger.LogInformation("Tenant {TenantId} blocked by activation gate (user: {Upn})", tenantId, upn);
|
||||
}
|
||||
|
||||
var blockedResponse = req.CreateResponse(decision.StatusCode);
|
||||
@@ -284,7 +295,7 @@ public class AuthFunction
|
||||
/// and fires best-effort notifications (Telegram + global notification).
|
||||
/// </summary>
|
||||
internal async Task HandleNewTenantDomainAsync(
|
||||
TenantConfiguration tenantConfig, string tenantId, string upn)
|
||||
TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tenantConfig.DomainName) || string.IsNullOrEmpty(upn))
|
||||
return;
|
||||
@@ -302,9 +313,21 @@ public class AuthFunction
|
||||
// sync jobs that mutate UpdatedBy cannot leak sentinel strings into TenantAdmins.
|
||||
if (string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy))
|
||||
tenantConfig.OnboardedBy = upn;
|
||||
// Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login
|
||||
// actually arrived via the primary app. A first login via the legacy app leaves the field
|
||||
// null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant
|
||||
// to an app it hasn't consented to. Seed the last-seen provenance in the same write.
|
||||
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
if (onboardingClientId != null)
|
||||
{
|
||||
if (_appRegistry.IsPrimary(onboardingClientId))
|
||||
tenantConfig.HomedAppClientId = onboardingClientId;
|
||||
tenantConfig.LastAuthClientId = onboardingClientId;
|
||||
tenantConfig.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig);
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "first-login domain seed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -325,9 +348,66 @@ public class AuthFunction
|
||||
// Fire-and-forget: Global notification
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"preview_signup",
|
||||
"New Preview Signup",
|
||||
"New Tenant Signup",
|
||||
$"Tenant {tenantId} ({domain}), UPN: {upn}",
|
||||
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
|
||||
|
||||
// Fire-and-forget: delayed auto-approve. Enqueued unconditionally — the worker is the
|
||||
// single decision point (checks AutoApproveNewTenants at processing time). A failed
|
||||
// enqueue degrades to manual approval and must never fail the login.
|
||||
_ = _tenantAutoApproveEnqueuer.EnqueueAsync(
|
||||
new Services.Activation.TenantAutoApproveEnvelope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SignupUpn = upn,
|
||||
EnqueuedAtUtc = DateTime.UtcNow
|
||||
},
|
||||
Services.Activation.TenantAutoApproveEnvelope.ActivationDelay)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget auto-approve enqueue failed for tenant {TenantId}", tenantId),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure observability for the app-reg migration: records which app registration this
|
||||
/// tenant's logins actually arrive through (<c>LastAuthClientId</c> + since-when).
|
||||
/// Write-on-change only — the common case (same app as last time) is a no-op, so this adds
|
||||
/// zero table writes to steady-state logins. Best-effort: a failed persist must never break
|
||||
/// auth/me; the next login that still differs retries. Gated on a non-empty DomainName so a
|
||||
/// pre-onboarding login (row not persisted yet) can't create a phantom config row.
|
||||
/// </summary>
|
||||
internal async Task HandleAuthClientIdTrackingAsync(
|
||||
TenantConfiguration tenantConfig, string tenantId, string? tokenAudience)
|
||||
{
|
||||
var clientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
if (clientId == null || string.IsNullOrEmpty(tenantConfig.DomainName))
|
||||
return;
|
||||
if (string.Equals(tenantConfig.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
|
||||
tenantId, clientId, tenantConfig.LastAuthClientId ?? "(none recorded)");
|
||||
try
|
||||
{
|
||||
// Mutate a cache-BYPASSING read, not the (up to 5 min stale on other instances)
|
||||
// cached view: this write persists the WHOLE entity, and the login that triggers it
|
||||
// is typically the very first one AFTER an app-homing flip — blind-saving the stale
|
||||
// view here would silently revert HomedAppClientId (same failure class as the
|
||||
// 2026-07-31 PUT revert incident). Change-detect above stays on the cached view as a
|
||||
// cheap pre-filter; the fresh read re-checks before writing.
|
||||
var fresh = await _tenantConfigService.GetConfigurationFreshAsync(tenantId);
|
||||
if (fresh == null || string.Equals(fresh.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
fresh.LastAuthClientId = clientId;
|
||||
fresh.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
await _tenantConfigService.SaveConfigurationAsync(fresh, "auth", "auth client-id tracking");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Auth client-id tracking persist failed for tenant {TenantId} — will retry on a later login", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -351,7 +431,7 @@ public class AuthFunction
|
||||
tenantConfig.UpdatedBy = "System (auto-re-enable)";
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig);
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "auto-re-enable after suspension expiry");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -394,7 +474,8 @@ public class AuthFunction
|
||||
McpAccessCheckResult mcpCheck,
|
||||
bool hasTenantAdmins,
|
||||
string tenantId, string upn, string displayName, string objectId,
|
||||
IReadOnlyCollection<string>? delegatedTenantIds = null)
|
||||
IReadOnlyCollection<string>? delegatedTenantIds = null,
|
||||
string homedApp = "primary")
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -415,13 +496,17 @@ public class AuthFunction
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 2: Preview gate (platform roles — GlobalAdmin / GlobalReader — and delegated MSP admins bypass)
|
||||
// Gate 2: Activation gate (platform roles — GlobalAdmin / GlobalReader — and delegated MSP
|
||||
// admins bypass). Wording is mode-neutral on purpose: with auto-approve on, activation
|
||||
// completes within a couple of minutes; with it off, the tenant waits for manual approval —
|
||||
// the message must hold for both without plumbing the flag into this pure method. The web
|
||||
// app accepts the legacy "PrivatePreview" code too, so backend and web deploy in any order.
|
||||
if (!isGlobalAdmin && !isGlobalReader && !isDelegated && !isPreviewApproved)
|
||||
{
|
||||
return AuthDecisionResult.Blocked(HttpStatusCode.Forbidden, new
|
||||
{
|
||||
error = "PrivatePreview",
|
||||
message = "Autopilot Monitor is currently in Private Preview. Your organization is on the waitlist \u2014 we'll notify you when access is granted."
|
||||
error = "PendingActivation",
|
||||
message = "Autopilot Monitor is free to use \u2014 every new organization goes through a short activation step. Your tenant is being activated; this usually completes within a couple of minutes."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -469,8 +554,16 @@ public class AuthFunction
|
||||
role,
|
||||
canManageBootstrapTokens,
|
||||
hasMcpAccess = mcpCheck.IsAllowed,
|
||||
bootstrapTokenEnabled = tenantConfig.BootstrapTokenEnabled,
|
||||
unrestrictedModeEnabled = tenantConfig.UnrestrictedModeEnabled
|
||||
// Dual app-reg window: which app registration this tenant is homed on. The web app
|
||||
// stores this per browser (localStorage) so the NEXT login uses the right app —
|
||||
// the deferred, no-roundtrip learning mechanism of the parallel operation model.
|
||||
homedApp,
|
||||
// EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is
|
||||
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
|
||||
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
|
||||
unrestrictedModeEnabled =
|
||||
FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
|
||||
&& tenantConfig.UnrestrictedModeEnabled
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ public class McpUserFunction
|
||||
var principal = context.GetUser();
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
|
||||
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Enterprise-only seat).
|
||||
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Pro-only seat).
|
||||
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId());
|
||||
|
||||
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
{
|
||||
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
|
||||
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
|
||||
// the requested tenant only. Home tid gates the MSP seat (Enterprise-only).
|
||||
// the requested tenant only. Home tid gates the MSP seat (Pro-only).
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
|
||||
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
|
||||
if (allowedCrossTenant)
|
||||
|
||||
+7
@@ -162,6 +162,13 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
"OffboardingMarkerCleanup: swept lingering TenantConfiguration tombstone for tenant={Tenant} (Phase 2.F-final must have failed in the handler)",
|
||||
marker.TenantId);
|
||||
}
|
||||
|
||||
// A save against the lingering tombstone (auth side effects) may have
|
||||
// re-created config backup snapshots AFTER the cascade's bulk wipe.
|
||||
// Sweep them under the same tombstone condition — a re-onboarded
|
||||
// tenant (else-branch) keeps its fresh backups untouched.
|
||||
await _safeWipe.WipeByExactPartitionAsync(
|
||||
Constants.TableNames.ConfigurationBackups, marker.TenantId, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Daily informational sweep over tenant Enterprise trials. NOT load-bearing — trial expiry
|
||||
/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry
|
||||
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
|
||||
/// this timer only surfaces the transitions as ops events so the operator has visibility:
|
||||
/// <list type="bullet">
|
||||
@@ -20,7 +20,7 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
|
||||
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
|
||||
/// </list>
|
||||
/// Tenants whose stored <c>PlanTier</c> is already the permanent "enterprise" are skipped —
|
||||
/// Tenants whose stored <c>PlanTier</c> is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped —
|
||||
/// their trial timestamps are inert leftovers and expiry changes nothing.
|
||||
/// Failure never poisons the timer; the next run re-sweeps.
|
||||
/// </summary>
|
||||
@@ -93,8 +93,8 @@ public class TrialExpirySweepFunction
|
||||
if (config.TrialExpiresUtc is not DateTime expiry)
|
||||
continue;
|
||||
|
||||
// Permanent Enterprise: the trial timestamps are inert leftovers — expiry changes nothing.
|
||||
if (string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
|
||||
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
|
||||
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
|
||||
continue;
|
||||
|
||||
result.TrialsSeen++;
|
||||
|
||||
+36
-102
@@ -2,7 +2,6 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -11,29 +10,28 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD endpoints for managing the Private Preview tenant whitelist.
|
||||
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
|
||||
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
|
||||
/// Temporary — remove after GA.
|
||||
/// </summary>
|
||||
public class PreviewWhitelistFunction
|
||||
{
|
||||
private readonly ILogger<PreviewWhitelistFunction> _logger;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantApprovalService _tenantApprovalService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly ResendEmailService _resendEmailService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
ILogger<PreviewWhitelistFunction> logger,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantApprovalService tenantApprovalService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
ResendEmailService resendEmailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantApprovalService = tenantApprovalService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_resendEmailService = resendEmailService;
|
||||
}
|
||||
|
||||
@@ -58,7 +56,7 @@ public class PreviewWhitelistFunction
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview/whitelist/{tenantId}
|
||||
/// Approves a tenant for Private Preview.
|
||||
/// Activates a tenant (adds it to the whitelist).
|
||||
/// </summary>
|
||||
[Function("ApprovePreviewTenant")]
|
||||
[Authorize]
|
||||
@@ -78,76 +76,23 @@ public class PreviewWhitelistFunction
|
||||
return bad;
|
||||
}
|
||||
|
||||
await _previewWhitelistService.ApproveAsync(tenantId, upn!);
|
||||
// Whitelist add + auto-promote + welcome email — shared with the auto-approve worker.
|
||||
// False = already activated: idempotent success, but no duplicate mail/promote.
|
||||
var newlyApproved = await _tenantApprovalService.ApproveWithSideEffectsAsync(tenantId, upn!);
|
||||
|
||||
_logger.LogInformation("Preview tenant approved: {TenantId} by {Upn}", tenantId, upn);
|
||||
|
||||
// Auto-promote the tenant requester (first user who triggered tenant config creation)
|
||||
// as TenantAdmin if they are not already one.
|
||||
// This ensures whoever signed up doesn't need manual admin assignment after approval.
|
||||
try
|
||||
var response = req.CreateResponse(newlyApproved ? HttpStatusCode.Created : HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
var requesterUpn = PickRequesterUpn(tenantConfig);
|
||||
|
||||
// Positive UPN-shape validation: real Azure AD UPNs always contain '@', while
|
||||
// every system-written sentinel ("System", "System (auto-re-enable)",
|
||||
// "System (Global Rate Limit Sync)", …) does not. An equality list against
|
||||
// known sentinels previously missed a third sentinel and corrupted 10 tenants'
|
||||
// TenantAdmins rows — keep this check shape-based, not enumeration-based.
|
||||
if (IsRealUserUpn(requesterUpn))
|
||||
{
|
||||
// IsRealUserUpn returned true → requesterUpn is non-null, non-empty, contains '@'
|
||||
var validUpn = requesterUpn!;
|
||||
var isAlreadyAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, validUpn);
|
||||
if (!isAlreadyAdmin)
|
||||
{
|
||||
await _tenantAdminsService.AddTenantAdminAsync(tenantId, validUpn, upn!);
|
||||
_logger.LogInformation(
|
||||
"Auto-promoted tenant requester {RequesterUpn} as TenantAdmin for tenant {TenantId} on preview approval by {ApprovedBy}",
|
||||
validUpn, tenantId, upn);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant requester {RequesterUpn} is already a TenantAdmin for tenant {TenantId} — skipping auto-promote",
|
||||
validUpn, tenantId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No valid tenant requester UPN found in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}', UpdatedBy: '{UpdatedBy}') — skipping auto-promote",
|
||||
tenantId, tenantConfig.OnboardedBy ?? "<null>", tenantConfig.UpdatedBy ?? "<null>");
|
||||
}
|
||||
|
||||
// Fire-and-forget: send welcome email if notification email is configured
|
||||
var notificationEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
|
||||
if (!string.IsNullOrWhiteSpace(notificationEmail))
|
||||
{
|
||||
_ = _resendEmailService.SendPreviewApprovedEmailAsync(
|
||||
notificationEmail, tenantConfig.DomainName)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget welcome email failed for tenant {TenantId}", tenantId),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: approval already succeeded, admin promotion is best-effort
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to auto-promote tenant requester as TenantAdmin for tenant {TenantId} — approval still succeeded",
|
||||
tenantId);
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new { message = "Tenant approved for preview", tenantId });
|
||||
message = newlyApproved ? "Tenant approved for preview" : "Tenant was already approved",
|
||||
tenantId,
|
||||
alreadyApproved = !newlyApproved,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/preview/whitelist/{tenantId}
|
||||
/// Revokes a tenant's Private Preview access.
|
||||
/// Revokes a tenant's activation (removes it from the whitelist).
|
||||
/// </summary>
|
||||
[Function("RevokePreviewTenant")]
|
||||
[Authorize]
|
||||
@@ -189,7 +134,7 @@ public class PreviewWhitelistFunction
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/preview/notification-email
|
||||
/// Saves the caller's notification email for Private Preview approval.
|
||||
/// Saves the caller's notification email for the activation notice.
|
||||
/// AuthenticatedUser policy — preview-blocked users can call this.
|
||||
/// </summary>
|
||||
[Function("SavePreviewNotificationEmail")]
|
||||
@@ -224,14 +169,27 @@ public class PreviewWhitelistFunction
|
||||
"Preview notification email updated for tenant {TenantId}: {Email}",
|
||||
tenantId, string.IsNullOrEmpty(email) ? "(cleared)" : email);
|
||||
|
||||
// Send-on-save half of the welcome-mail race: with auto-approve, activation
|
||||
// typically finishes before the user has typed this address on the activation
|
||||
// page — the approval path then found no address and deferred to us. Fresh
|
||||
// approval read: a cached "not approved" can be stale for exactly this window.
|
||||
// The sent-marker inside TrySendWelcomeEmailAsync dedupes against the approval
|
||||
// path and against repeated saves.
|
||||
var welcomeEmailSent = false;
|
||||
if (!string.IsNullOrWhiteSpace(email) &&
|
||||
await _previewWhitelistService.IsApprovedFreshAsync(tenantId))
|
||||
{
|
||||
welcomeEmailSent = await _tenantApprovalService.TrySendWelcomeEmailAsync(tenantId);
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { message = "Notification email saved", email });
|
||||
await response.WriteAsJsonAsync(new { message = "Notification email saved", email, welcomeEmailSent });
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview/send-welcome-email/{tenantId}
|
||||
/// Sends (or resends) the Private Preview welcome email. Global Admin only.
|
||||
/// Sends (or resends) the activation welcome email. Global Admin only.
|
||||
/// Accepts optional { email } in body — if provided, saves it to PreviewWhitelist table before sending.
|
||||
/// </summary>
|
||||
[Function("SendPreviewWelcomeEmail")]
|
||||
@@ -265,6 +223,11 @@ public class PreviewWhitelistFunction
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
|
||||
|
||||
// Explicit GA send always sends; consume the once-only marker (best-effort) so
|
||||
// the automatic paths won't produce a duplicate afterwards.
|
||||
try { await _previewWhitelistService.TryMarkWelcomeEmailSentAsync(tenantId); }
|
||||
catch { /* best-effort — a failed marker write must not fail the explicit send */ }
|
||||
|
||||
var principal = context.GetUser();
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
_logger.LogInformation(
|
||||
@@ -276,35 +239,6 @@ public class PreviewWhitelistFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the UPN of the user to auto-promote as TenantAdmin on preview approval.
|
||||
/// Prefers <see cref="TenantConfiguration.OnboardedBy"/> (immutable, set once on first
|
||||
/// user login) over <see cref="TenantConfiguration.UpdatedBy"/> (mutable, can be
|
||||
/// clobbered by background jobs such as the global rate-limit sync). Fall back to
|
||||
/// <see cref="TenantConfiguration.UpdatedBy"/> for tenants onboarded before
|
||||
/// <see cref="TenantConfiguration.OnboardedBy"/> existed — callers still guard the
|
||||
/// result with <see cref="IsRealUserUpn"/> so a sentinel value cannot leak into
|
||||
/// the TenantAdmins table.
|
||||
/// </summary>
|
||||
internal static string? PickRequesterUpn(TenantConfiguration config)
|
||||
{
|
||||
if (config == null) return null;
|
||||
return !string.IsNullOrWhiteSpace(config.OnboardedBy)
|
||||
? config.OnboardedBy
|
||||
: config.UpdatedBy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the value looks like a real Azure AD user principal name
|
||||
/// (contains '@' and does not start with the "System" sentinel prefix
|
||||
/// used by background jobs that touch <see cref="TenantConfiguration.UpdatedBy"/>).
|
||||
/// </summary>
|
||||
internal static bool IsRealUserUpn(string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn)) return false;
|
||||
if (upn.StartsWith("System", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return upn.Contains('@');
|
||||
}
|
||||
}
|
||||
|
||||
public class SaveNotificationEmailRequest
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Precise, unmasked property-level comparison of two config objects. Returns the NAMES
|
||||
/// of every changed property (values never leave this method), excluding only the table
|
||||
/// plumbing keys — unlike <see cref="ConfigDiffHelper"/>, LastUpdated/UpdatedBy/TenantId
|
||||
/// ARE visible, because callers (backup noise filter, transactional write verification)
|
||||
/// must see every real difference.
|
||||
/// </summary>
|
||||
public static class ConfigPropertyComparer
|
||||
{
|
||||
private static readonly HashSet<string> ExcludedProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"PartitionKey", "RowKey", "Timestamp", "ETag",
|
||||
};
|
||||
|
||||
public static HashSet<string> GetChangedPropertyNames<T>(T a, T b) where T : class
|
||||
{
|
||||
var changed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (a == null || b == null) throw new ArgumentNullException(a == null ? nameof(a) : nameof(b));
|
||||
|
||||
foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!prop.CanRead) continue;
|
||||
if (prop.GetIndexParameters().Length > 0) continue;
|
||||
if (ExcludedProperties.Contains(prop.Name)) continue;
|
||||
|
||||
if (!Equals(prop.GetValue(a), prop.GetValue(b)))
|
||||
changed.Add(prop.Name);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
("onboardedBy", c => c.OnboardedBy),
|
||||
("lastUpdated", c => c.LastUpdated),
|
||||
("dataRetentionDays", c => c.DataRetentionDays),
|
||||
// App-reg migration observability: which app the tenant is homed on (null = legacy)
|
||||
// and which app its users' logins actually arrive through. Public client ids, not secrets.
|
||||
("homedAppClientId", c => c.HomedAppClientId),
|
||||
("lastAuthClientId", c => c.LastAuthClientId),
|
||||
("lastAuthClientIdSince", c => c.LastAuthClientIdSince),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Single source for tenant-configuration model validation, shared by the full-model
|
||||
/// PUT (<c>UpdateTenantConfigurationFunction</c>) and the transactional field-patch
|
||||
/// flow (<c>TenantConfigPatchService</c>). The individual validators moved here from
|
||||
/// the PUT function; thin forwarding shims remain there for existing callers/tests.
|
||||
/// </summary>
|
||||
internal static class TenantConfigValidation
|
||||
{
|
||||
private const int MaxCustomHeadersJsonLength = 8192;
|
||||
private const int MaxCustomHeaderCount = 25;
|
||||
private const int MaxNotificationChannelsJsonLength = 65536;
|
||||
|
||||
// RFC 5321 caps a forward path at 254 characters.
|
||||
internal const int MaxContactEmailLength = 254;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a candidate configuration against the stored one. Returns a
|
||||
/// user-facing error message (same wording the PUT endpoint always produced),
|
||||
/// or null when the candidate is saveable. Does NOT mutate either argument.
|
||||
/// </summary>
|
||||
internal static string? ValidateModel(
|
||||
TenantConfiguration candidate, TenantConfiguration existing, bool isGlobalAdmin)
|
||||
{
|
||||
// Per-tenant rate-limit overrides are optional (null = inherit global), but if provided
|
||||
// they must be positive — a zero/negative override would throttle every request.
|
||||
var customLimitError =
|
||||
candidate.CustomRateLimitRequestsPerMinute is int dev && dev < 1 ? "Device API Rate Limit" :
|
||||
candidate.CustomUserRateLimitRequestsPerMinute is int usr && usr < 1 ? "User API Rate Limit" :
|
||||
null;
|
||||
if (customLimitError != null)
|
||||
return $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default).";
|
||||
|
||||
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
return $"Invalid contact email: {contactEmailError}";
|
||||
|
||||
var webhookUrlError = SsrfGuard.ValidateWebhookUrlFormat(candidate.WebhookUrl);
|
||||
if (webhookUrlError != null)
|
||||
return $"Invalid Webhook URL: {webhookUrlError}";
|
||||
|
||||
var teamsUrlError = SsrfGuard.ValidateWebhookUrlFormat(candidate.TeamsWebhookUrl);
|
||||
if (teamsUrlError != null)
|
||||
return $"Invalid Teams Webhook URL: {teamsUrlError}";
|
||||
|
||||
var headersError = ValidateWebhookCustomHeaders(candidate.WebhookCustomHeadersJson);
|
||||
if (headersError != null)
|
||||
return $"Invalid custom headers: {headersError}";
|
||||
|
||||
var channelsError = ValidateNotificationChannels(candidate.NotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
return $"Invalid notification channels: {channelsError}";
|
||||
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually selected
|
||||
// the CustomerSas destination — a stale value left over from a prior CustomerSas
|
||||
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
|
||||
var diagDestination = Functions.Diagnostics.GetDiagnosticsUploadUrlFunction
|
||||
.NormalizeDestination(candidate.DiagnosticsUploadDestination);
|
||||
if (diagDestination == Functions.Diagnostics.GetDiagnosticsUploadUrlFunction.DestinationCustomerSas)
|
||||
{
|
||||
var diagSasError = SsrfGuard.ValidateAzureBlobSasUrlFormat(candidate.DiagnosticsBlobSasUrl);
|
||||
if (diagSasError != null)
|
||||
return $"Invalid Diagnostics SAS URL: {diagSasError}";
|
||||
}
|
||||
|
||||
// Retention cap (edition entitlement): non-GA callers may only set 7..cap days, and
|
||||
// only when they actually CHANGED the value — a stored value predating the cap must
|
||||
// not block unrelated saves. 0 (= infinite) is a GA-only escape hatch. Edition
|
||||
// resolves from the STORED config.
|
||||
if (!isGlobalAdmin && candidate.DataRetentionDays != existing.DataRetentionDays)
|
||||
{
|
||||
var cap = FeatureEntitlementCatalog
|
||||
.Get(TenantEntitlementService.ResolveEdition(existing, DateTime.UtcNow))
|
||||
.RetentionCapDays;
|
||||
if (candidate.DataRetentionDays < 7 || candidate.DataRetentionDays > cap)
|
||||
return $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Pro for up to 365 days.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the tenant contact address. Returns an error message, or null when valid/empty.
|
||||
/// Empty is legitimate — it means we have no way to reach the tenant.
|
||||
/// <para>
|
||||
/// Deliberately not an RFC 5322 parser: the job is to reject values that are not addresses
|
||||
/// at all. Specifically it rejects recipient lists (a comma would silently widen who receives
|
||||
/// service notices), display-name forms, and control characters (which would let a caller
|
||||
/// forge mail headers once this address is actually mailed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return null;
|
||||
|
||||
var trimmed = email.Trim();
|
||||
|
||||
if (trimmed.Length > MaxContactEmailLength)
|
||||
return $"must be at most {MaxContactEmailLength} characters.";
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "must not contain control characters.";
|
||||
if (char.IsWhiteSpace(ch) || ch == ',' || ch == ';' || ch == '<' || ch == '>')
|
||||
return "must be a single address, without spaces, separators or angle brackets.";
|
||||
}
|
||||
|
||||
var at = trimmed.IndexOf('@');
|
||||
if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1)
|
||||
return "must contain a single \"@\" with text on both sides.";
|
||||
|
||||
// A bare host with no dot is unreachable from our sender, so it is a typo, not an address.
|
||||
var domain = trimmed.Substring(at + 1);
|
||||
if (!domain.Contains('.') || domain.StartsWith(".", StringComparison.Ordinal)
|
||||
|| domain.EndsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return "the domain part must be a dotted host name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the notification-channel list JSON. Returns an error message, or null when
|
||||
/// valid/empty. Strict counterpart of the fail-soft <c>NotificationChannel.ParseList</c>:
|
||||
/// entries the parser would silently drop (missing id, unknown provider) are rejected here
|
||||
/// so a tenant admin gets feedback instead of a channel that never fires. Each channel's
|
||||
/// URL and custom headers pass the same gates as the legacy single-webhook fields.
|
||||
/// </summary>
|
||||
internal static string? ValidateNotificationChannels(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
if (json.Length > MaxNotificationChannelsJsonLength)
|
||||
return $"too large (max {MaxNotificationChannelsJsonLength} characters).";
|
||||
|
||||
List<Shared.Models.Notifications.NotificationChannel>? channels;
|
||||
try
|
||||
{
|
||||
channels = System.Text.Json.JsonSerializer.Deserialize<List<Shared.Models.Notifications.NotificationChannel>>(
|
||||
json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return "not valid JSON.";
|
||||
}
|
||||
|
||||
if (channels == null)
|
||||
return "must be a JSON array of channels.";
|
||||
|
||||
if (channels.Count > Shared.Models.Notifications.NotificationChannel.MaxChannelsPerTenant)
|
||||
return $"too many channels (max {Shared.Models.Notifications.NotificationChannel.MaxChannelsPerTenant}).";
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (channel == null || string.IsNullOrWhiteSpace(channel.Id))
|
||||
return "every channel needs an id.";
|
||||
if (!ids.Add(channel.Id))
|
||||
return $"duplicate channel id \"{channel.Id}\".";
|
||||
|
||||
var label = string.IsNullOrWhiteSpace(channel.Name) ? channel.Id : channel.Name;
|
||||
|
||||
if (!Enum.IsDefined(typeof(Shared.Models.Notifications.WebhookProviderType), channel.ProviderType)
|
||||
|| channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.None)
|
||||
return $"channel \"{label}\" has an invalid provider type.";
|
||||
|
||||
var urlError = SsrfGuard.ValidateWebhookUrlFormat(channel.Url);
|
||||
if (urlError != null)
|
||||
return $"channel \"{label}\": {urlError}";
|
||||
|
||||
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
|
||||
if (headerError != null)
|
||||
return $"channel \"{label}\" headers: {headerError}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when
|
||||
/// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF
|
||||
/// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected
|
||||
/// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders().
|
||||
/// </summary>
|
||||
internal static string? ValidateWebhookCustomHeaders(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
if (json.Length > MaxCustomHeadersJsonLength)
|
||||
return $"too large (max {MaxCustomHeadersJsonLength} characters).";
|
||||
|
||||
System.Text.Json.JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return "not valid JSON.";
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return "must be a JSON object of header name/value pairs.";
|
||||
|
||||
var count = 0;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (++count > MaxCustomHeaderCount)
|
||||
return $"too many headers (max {MaxCustomHeaderCount}).";
|
||||
|
||||
if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
return $"header \"{prop.Name}\" must have a string value.";
|
||||
|
||||
if (!IsValidHeaderName(prop.Name))
|
||||
return $"\"{prop.Name}\" is not a valid HTTP header name.";
|
||||
|
||||
var value = prop.Value.GetString();
|
||||
if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0))
|
||||
return $"value for \"{prop.Name}\" must not contain line breaks.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators).</summary>
|
||||
private static bool IsValidHeaderName(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
foreach (var ch in name)
|
||||
{
|
||||
var isTokenChar =
|
||||
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
|
||||
"!#$%&'*+-.^_`|~".IndexOf(ch) >= 0;
|
||||
if (!isTokenChar)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,12 +165,15 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// Extracted to a pure seam so those rejects are unit-testable against locally-minted
|
||||
// tokens without a live OIDC ConfigurationManager.
|
||||
//
|
||||
// Audience = the primary app registration (EntraId:ClientId) PLUS any additional
|
||||
// (secondary/rotated) client IDs from EntraId:AdditionalClientIds. That config is the
|
||||
// seam that lets the API trust a SECOND app registration during a tenant-move window
|
||||
// (see tasks/migration-cross-tenant-runbook.md). Unset today ⇒ exactly the primary id
|
||||
// ⇒ zero behaviour change.
|
||||
var additionalClientIdsRaw = _configuration["EntraId:AdditionalClientIds"];
|
||||
// Audience = the primary app registration (EntraId:ClientId) PLUS the legacy app
|
||||
// (EntraId:LegacyClientId — the dual app-registration parallel window, see
|
||||
// EntraAppRegistry) PLUS any additional (secondary/rotated) client IDs from
|
||||
// EntraId:AdditionalClientIds. Folding the legacy id in here means configuring the
|
||||
// legacy pair alone is sufficient for inbound trust — no separate AdditionalClientIds
|
||||
// entry required. All extra sources unset ⇒ exactly the primary id ⇒ zero change.
|
||||
var additionalClientIdsRaw = CombineAdditionalClientIdSources(
|
||||
_configuration["EntraId:LegacyClientId"],
|
||||
_configuration["EntraId:AdditionalClientIds"]);
|
||||
var clientIds = ResolveConfiguredClientIds(
|
||||
_configuration["EntraId:ClientId"], additionalClientIdsRaw, out var rejectedAdditionalEntries);
|
||||
AuditClientIdTrustSet(additionalClientIdsRaw, clientIds, rejectedAdditionalEntries);
|
||||
@@ -342,6 +345,18 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
/// needed to trust a second app during a tenant-move window
|
||||
/// (tasks/migration-cross-tenant-runbook.md).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Joins multiple "additional client id" config sources (e.g. <c>EntraId:LegacyClientId</c> and
|
||||
/// <c>EntraId:AdditionalClientIds</c>) into one comma-separated string for
|
||||
/// <see cref="ResolveConfiguredClientIds"/>. Null/whitespace sources are skipped; all-empty
|
||||
/// input yields null (identical to "no additional ids configured").
|
||||
/// </summary>
|
||||
internal static string? CombineAdditionalClientIdSources(params string?[] sources)
|
||||
{
|
||||
var nonEmpty = sources.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s!.Trim()).ToArray();
|
||||
return nonEmpty.Length > 0 ? string.Join(",", nonEmpty) : null;
|
||||
}
|
||||
|
||||
internal static string[] ResolveConfiguredClientIds(
|
||||
string? primaryClientId, string? additionalClientIds, out string[] rejectedAdditionalEntries)
|
||||
{
|
||||
|
||||
@@ -166,7 +166,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
|
||||
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
|
||||
{
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
||||
delegatedRole = scope.RoleFor(namedTarget);
|
||||
if (delegatedRole != null)
|
||||
@@ -579,7 +579,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (globalRole != null)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
|
||||
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
||||
if (!scope.IsEmpty)
|
||||
return CatalogDecisionResult.Allow(
|
||||
|
||||
@@ -83,7 +83,7 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
tenantOverride = tenantConfig?.CustomUserRateLimitRequestsPerMinute;
|
||||
}
|
||||
|
||||
// Edition entitlement floor (Enterprise: 150/min): follows the caller's HOME tenant
|
||||
// Edition entitlement floor (Pro: 150/min): follows the caller's HOME tenant
|
||||
// (JWT tid) — an MSP/delegated user rides on the edition of the tenant that pays for
|
||||
// their seat, not the tenant they happen to be viewing. Resolution is fail-closed
|
||||
// (any error → Community → null floor → admin default applies) and served from the
|
||||
|
||||
@@ -121,14 +121,17 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/organizations/v2.0",
|
||||
"https://sts.windows.net/{tenantid}/"
|
||||
},
|
||||
// Temporarily accept Microsoft Graph tokens (used by frontend with User.Read scope)
|
||||
// TODO: Later expose custom API and use api://{clientId} scopes
|
||||
ValidAudiences = new[]
|
||||
{
|
||||
builder.Configuration["EntraId:ClientId"] // Our app's client ID
|
||||
//"https://graph.microsoft.com", // Microsoft Graph
|
||||
//"00000003-0000-0000-c000-000000000000" // Microsoft Graph App ID
|
||||
}
|
||||
// Full audience trust set — MUST match AuthenticationMiddleware's per-request set
|
||||
// (primary + legacy + additional client ids, each in bare and api:// form). Built via
|
||||
// the SAME shared helpers so the two lists cannot drift; a single-audience list here
|
||||
// would 401 legacy-app tokens on [Authorize]-gated paths during the dual app-reg window.
|
||||
ValidAudiences = AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.BuildValidAudiences(
|
||||
AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.ResolveConfiguredClientIds(
|
||||
builder.Configuration["EntraId:ClientId"],
|
||||
AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.CombineAdditionalClientIdSources(
|
||||
builder.Configuration["EntraId:LegacyClientId"],
|
||||
builder.Configuration["EntraId:AdditionalClientIds"]),
|
||||
out _))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -229,7 +232,9 @@ builder.Services.AddHostedService<TableInitializerService>(); // Initialize all
|
||||
// To add event streaming: chain .AddEventStreaming<EventHubPublisher>() after this call.
|
||||
builder.Services.AddTableStorageDataAccess();
|
||||
builder.Services.AddSingleton<TenantConfigurationService>();
|
||||
builder.Services.AddSingleton<TenantConfigPatchService>();
|
||||
builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<AppHomingService>();
|
||||
builder.Services.AddSingleton<TenantEntitlementService>();
|
||||
builder.Services.AddSingleton<McpQuotaService>();
|
||||
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
|
||||
@@ -243,6 +248,9 @@ builder.Services.AddSingleton<GlobalAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<McpUserService>();
|
||||
builder.Services.AddSingleton<PreviewWhitelistService>();
|
||||
// Shared activation path (whitelist add + auto-promote + welcome email) — used by the
|
||||
// Global Admin approve endpoint and the tenant auto-approve queue worker.
|
||||
builder.Services.AddSingleton<TenantApprovalService>();
|
||||
builder.Services.AddSingleton<TenantAdminsService>();
|
||||
builder.Services.AddSingleton<HealthCheckService>();
|
||||
builder.Services.AddSingleton<BackendBuildInfo>();
|
||||
@@ -323,6 +331,18 @@ builder.Services.AddSingleton<
|
||||
builder.Services.AddHostedService<
|
||||
AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateQueueWorker>();
|
||||
|
||||
// Delayed tenant auto-approve (public availability). Producer fires at first-login signup
|
||||
// with a ~1-minute visibility delay; the worker activates the tenant via the shared
|
||||
// TenantApprovalService only if AdminConfiguration.AutoApproveNewTenants is enabled at
|
||||
// processing time. Same producer + worker pattern as the analyze queue above.
|
||||
builder.Services.AddSingleton<
|
||||
AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer,
|
||||
AutopilotMonitor.Functions.Services.Activation.AzureQueueTenantAutoApproveEnqueuer>();
|
||||
builder.Services.AddSingleton<
|
||||
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveHandler>();
|
||||
builder.Services.AddHostedService<
|
||||
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveQueueWorker>();
|
||||
|
||||
// ── Critical-Table Backup (plan §PR1) ─────────────────────────────────────────
|
||||
// Lease-aware service registered separately from the timer/worker so the lease
|
||||
// boundary is testable in isolation. JobsRepository is the canonical persistence
|
||||
@@ -369,6 +389,7 @@ builder.Services.AddSingleton<EventIngestProcessor>();
|
||||
builder.Services.AddSingleton<RegisterSessionFunction>();
|
||||
builder.Services.AddSingleton<GetAgentConfigFunction>();
|
||||
builder.Services.AddSingleton<ReportAgentErrorFunction>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.EntraAppRegistry>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
|
||||
@@ -406,6 +427,13 @@ if (string.IsNullOrEmpty(entraClientId))
|
||||
startupLogger.LogWarning("EntraId:ClientId is not configured — JWT audience validation and Graph API calls will fail");
|
||||
if (string.IsNullOrEmpty(entraClientSecret))
|
||||
startupLogger.LogWarning("EntraId:ClientSecret is not configured — device validation via Graph API will fail at runtime");
|
||||
// Dual app-registration window: the legacy pair must be set together — an id without a secret
|
||||
// breaks Graph for every legacy-homed tenant, a secret without an id is dead config.
|
||||
var entraLegacyClientId = builder.Configuration["EntraId:LegacyClientId"];
|
||||
var entraLegacyClientSecret = builder.Configuration["EntraId:LegacyClientSecret"];
|
||||
if (string.IsNullOrEmpty(entraLegacyClientId) != string.IsNullOrEmpty(entraLegacyClientSecret))
|
||||
startupLogger.LogWarning(
|
||||
"EntraId:LegacyClientId and EntraId:LegacyClientSecret must be configured together — only one is set, legacy-homed tenants will fail Graph token acquisition");
|
||||
|
||||
// Log CORS configuration at startup so misconfigured origins are immediately visible
|
||||
// in the log stream. CORS is enforced by Azure infrastructure, not by function code,
|
||||
|
||||
@@ -308,6 +308,10 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/access-check", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Dual app-reg homing flip. Tenant-admin tier by design (self-service migration), but the
|
||||
// function re-gates: non-GA callers only flip TO primary, never force, only while the
|
||||
// SelfServiceAppHomingEnabled kill switch is on, and only after the live consent probe.
|
||||
new("POST", "config/{tenantId}/app-homing", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/test-notification", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
|
||||
// ── Graph add-on permissions ────────────────────────────────────────
|
||||
@@ -386,6 +390,18 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
|
||||
// Transactional field-level config writes + snapshot list/revert (MCP-first surface).
|
||||
// GlobalAdminOnly for now; phase 2 widens these three lines to TenantAdminOrGA (own row)
|
||||
// and a delegated-admin write policy — the service's caller-tier field deny-lists and
|
||||
// TenantScoping.RouteParam already carry the per-tenant guardrail. The backups GET is
|
||||
// metadata-only (masked diff, never the raw EntityJson snapshot).
|
||||
new("PATCH", "config/{tenantId}/fields", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Field schema for the write surface above: tenant-independent reflection over the model
|
||||
// + deny-lists (no tenant data), but GA-only to match the surface it describes. Literal
|
||||
// route — FindPolicy and ASP.NET routing both prefer it over config/{tenantId}.
|
||||
new("GET", "config/fields-schema", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "auth/global-admins", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("POST", "auth/global-admins", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "auth/global-admins/{upn}", EndpointPolicy.GlobalAdminOnly),
|
||||
@@ -519,8 +535,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "global/tenant-groups/{groupId}/assignees", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/tenant-groups/{groupId}/assignees/{upn}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Self-service Enterprise trial — a tenant admin may start their own tenant's one-time
|
||||
// trial; the handler enforces the once-per-tenant + already-Enterprise conflicts (409).
|
||||
// Self-service Pro trial — a tenant admin may start their own tenant's one-time
|
||||
// trial; the handler enforces the once-per-tenant + already-Pro conflicts (409).
|
||||
new("POST", "config/{tenantId}/trial", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Credentials of one Entra app registration the platform operates under.
|
||||
/// <see cref="IsLegacy"/> marks the pre-migration app (gktatooine.net home tenant).
|
||||
/// </summary>
|
||||
public sealed record EntraAppCredentials(string ClientId, string? ClientSecret, bool IsLegacy);
|
||||
|
||||
/// <summary>
|
||||
/// Single source for "which Entra app registration acts on behalf of this tenant" during the
|
||||
/// dual app-registration parallel window (legacy gktatooine app ∥ new C4A8 app).
|
||||
///
|
||||
/// Config axes:
|
||||
/// - <c>EntraId:ClientId</c> / <c>EntraId:ClientSecret</c> — the PRIMARY app (post-swap: the new C4A8 app).
|
||||
/// - <c>EntraId:LegacyClientId</c> / <c>EntraId:LegacyClientSecret</c> — the pre-migration app.
|
||||
///
|
||||
/// Resolution contract (<see cref="ResolveForTenant"/>):
|
||||
/// - Legacy app not configured ⇒ ALWAYS primary. This makes deploying this code a no-op until
|
||||
/// the operator performs the config swap (primary→new + legacy→old) — before that swap the
|
||||
/// primary IS the old app and behaviour is byte-identical to today.
|
||||
/// - <see cref="TenantConfiguration.HomedAppClientId"/> null ⇒ legacy (the invariant: every
|
||||
/// tenant onboarded before the migration has no homing value and stays on the old app).
|
||||
/// - Equal to the primary/legacy client id ⇒ that app. Unknown value ⇒ primary + warning
|
||||
/// (fail toward the app new consents land on; an unknown id is an operator typo).
|
||||
///
|
||||
/// Token VALIDATION (inbound audiences) is not decided here — the middleware trusts primary +
|
||||
/// legacy + EntraId:AdditionalClientIds simultaneously; this registry only picks the app used
|
||||
/// for OUTBOUND artifacts: Graph client-credential tokens, admin-consent URLs, and the client
|
||||
/// id surfaced to tenant admins.
|
||||
/// </summary>
|
||||
public class EntraAppRegistry
|
||||
{
|
||||
private readonly ILogger<EntraAppRegistry> _logger;
|
||||
private readonly EntraAppCredentials _primary;
|
||||
private readonly EntraAppCredentials? _legacy;
|
||||
private readonly string? _primaryClientIdNormalized;
|
||||
|
||||
public EntraAppRegistry(IConfiguration configuration, ILogger<EntraAppRegistry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
// Primary stays verbatim (same contract as AuthenticationMiddleware: its handling is
|
||||
// unchanged and never validated away). Normalized form is only used for comparisons.
|
||||
var primaryClientId = configuration["EntraId:ClientId"] ?? string.Empty;
|
||||
_primary = new EntraAppCredentials(primaryClientId, configuration["EntraId:ClientSecret"], IsLegacy: false);
|
||||
_primaryClientIdNormalized = NormalizeClientId(primaryClientId);
|
||||
|
||||
var legacyClientIdRaw = configuration["EntraId:LegacyClientId"];
|
||||
var legacyClientId = NormalizeClientId(legacyClientIdRaw);
|
||||
if (legacyClientId != null)
|
||||
{
|
||||
_legacy = new EntraAppCredentials(legacyClientId, configuration["EntraId:LegacyClientSecret"], IsLegacy: true);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(legacyClientIdRaw))
|
||||
{
|
||||
// Malformed (non-GUID) legacy id: treat as unset (fail toward primary-only, i.e.
|
||||
// pre-swap behaviour) but make the misconfiguration operator-visible — a typo here
|
||||
// would otherwise surface as "legacy tenants lost Graph/consent" with no clue.
|
||||
_logger.LogWarning(
|
||||
"EntraId:LegacyClientId is not a valid GUID — ignoring the legacy app registration: {Entry}",
|
||||
AuthenticationMiddleware.TruncateForLog(legacyClientIdRaw.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The primary app registration (post-swap: the new C4A8 app).</summary>
|
||||
public EntraAppCredentials Primary => _primary;
|
||||
|
||||
/// <summary>The legacy app registration, or null while the config swap hasn't happened.</summary>
|
||||
public EntraAppCredentials? Legacy => _legacy;
|
||||
|
||||
/// <summary>True once both app registrations are configured (the parallel window is active).</summary>
|
||||
public bool LegacyConfigured => _legacy != null;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the app registration that acts on behalf of the given tenant (Graph
|
||||
/// client-credential tokens, admin-consent URLs, surfaced client id).
|
||||
/// </summary>
|
||||
public virtual EntraAppCredentials ResolveForTenant(TenantConfiguration? tenantConfig)
|
||||
{
|
||||
if (_legacy == null)
|
||||
{
|
||||
return _primary;
|
||||
}
|
||||
|
||||
var homed = NormalizeClientId(tenantConfig?.HomedAppClientId);
|
||||
if (homed == null)
|
||||
{
|
||||
// Null homing = onboarded before the migration ⇒ legacy app.
|
||||
return _legacy;
|
||||
}
|
||||
if (homed.Equals(_primaryClientIdNormalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _primary;
|
||||
}
|
||||
if (homed.Equals(_legacy.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _legacy;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId} has unknown HomedAppClientId {HomedAppClientId} — falling back to the primary app registration",
|
||||
tenantConfig?.TenantId, homed);
|
||||
return _primary;
|
||||
}
|
||||
|
||||
/// <summary>True when the given client id (bare or api://-prefixed) is the primary app's.</summary>
|
||||
public virtual bool IsPrimary(string? clientIdOrAudience)
|
||||
{
|
||||
var normalized = NormalizeAudience(clientIdOrAudience);
|
||||
return normalized != null
|
||||
&& normalized.Equals(_primaryClientIdNormalized, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a token audience to the canonical lowercase dashed client id: strips an
|
||||
/// optional <c>api://</c> prefix and requires a GUID. Null for anything else — a Graph or
|
||||
/// unknown audience must never be persisted as app-reg provenance.
|
||||
/// </summary>
|
||||
public static string? NormalizeAudience(string? audience)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience)) return null;
|
||||
var value = audience.Trim();
|
||||
const string apiPrefix = "api://";
|
||||
if (value.StartsWith(apiPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = value.Substring(apiPrefix.Length);
|
||||
}
|
||||
return NormalizeClientId(value);
|
||||
}
|
||||
|
||||
/// <summary>Canonical lowercase dashed GUID form, or null when not a GUID.</summary>
|
||||
public static string? NormalizeClientId(string? clientId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId)) return null;
|
||||
return Guid.TryParse(clientId.Trim(), out var parsed) ? parsed.ToString("D") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ using System;
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The two effective tenant editions. Anything that is not provably Enterprise is Community
|
||||
/// (fail-closed) — including the legacy stored tiers "free" and "pro" and unknown values.
|
||||
/// The two effective tenant editions. Anything that is not provably Pro is Community
|
||||
/// (fail-closed) — including the legacy stored tier "free" and unknown values.
|
||||
/// </summary>
|
||||
public enum TenantEdition
|
||||
{
|
||||
Community,
|
||||
Enterprise
|
||||
Pro
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,11 +43,27 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <summary>
|
||||
/// Whether users HOMED in this tenant may hold delegated ("MSP") admin scopes over other
|
||||
/// tenants. The gate applies to the delegated admin's home tenant (JWT tid) — the managed
|
||||
/// TARGET tenants may be any edition (an Enterprise MSP may manage Community customers).
|
||||
/// TARGET tenants may be any edition (a Pro MSP may manage Community customers).
|
||||
/// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
|
||||
/// </summary>
|
||||
public bool DelegatedAdminAllowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the OOBE bootstrap feature (bootstrap sessions/codes) is included in the plan.
|
||||
/// When true, bootstrap endpoints work without the per-tenant GA flag
|
||||
/// (TenantConfiguration.BootstrapTokenEnabled remains an additive per-tenant enable for
|
||||
/// Community). Resolved via <see cref="Services.TenantEntitlementService.IsBootstrapEnabled"/>.
|
||||
/// </summary>
|
||||
public bool BootstrapIncluded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Unrestricted Mode may be active for tenants of this edition. Availability stays
|
||||
/// on-request (the GA-only UnrestrictedModeEnabled gate) — this flag additionally binds the
|
||||
/// feature to the plan so a trial expiry / downgrade re-arms the guardrails (fail-closed).
|
||||
/// Resolved via <see cref="Services.TenantEntitlementService.IsUnrestrictedModeActive"/>.
|
||||
/// </summary>
|
||||
public bool UnrestrictedModeAvailable { get; init; }
|
||||
|
||||
/// <summary>Default MCP usage plan name for users of this tenant (per-user override wins).</summary>
|
||||
public string McpUsagePlanName { get; init; } = string.Empty;
|
||||
|
||||
@@ -61,16 +77,23 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <summary>
|
||||
/// Single source of truth for edition resolution and the per-edition entitlement matrix.
|
||||
///
|
||||
/// Resolution is computed at READ time: Enterprise ⇔ PlanTier == "enterprise" OR an active
|
||||
/// trial (TrialExpiresUtc strictly in the future). Trial expiry therefore degrades the tenant
|
||||
/// automatically without any timer or sweep. Everything else — null, empty, "free", "pro",
|
||||
/// Resolution is computed at READ time: Pro ⇔ PlanTier == "pro" (or the legacy stored value
|
||||
/// "enterprise", kept readable so existing rows need no migration) OR an active trial
|
||||
/// (TrialExpiresUtc strictly in the future). Trial expiry therefore degrades the tenant
|
||||
/// automatically without any timer or sweep. Everything else — null, empty, "free",
|
||||
/// unknown — is Community (fail-closed, no data migration required).
|
||||
/// </summary>
|
||||
public static class FeatureEntitlementCatalog
|
||||
{
|
||||
/// <summary>Write-side canonical tier names (the only values the plan endpoint accepts).</summary>
|
||||
public const string CommunityTierName = "community";
|
||||
public const string EnterpriseTierName = "enterprise";
|
||||
public const string ProTierName = "pro";
|
||||
|
||||
/// <summary>
|
||||
/// Stored tier value written before the 2026-07 Enterprise→Pro rename. Read-side alias for
|
||||
/// <see cref="ProTierName"/> only — the plan endpoint no longer accepts it on writes.
|
||||
/// </summary>
|
||||
public const string LegacyEnterpriseTierName = "enterprise";
|
||||
|
||||
private static readonly EditionEntitlements Community = new()
|
||||
{
|
||||
@@ -79,41 +102,57 @@ namespace AutopilotMonitor.Functions.Security
|
||||
UserRateLimitPerMinute = null, // AdminConfiguration default (120) applies
|
||||
DeviceRateLimitPerMinute = null, // AdminConfiguration default (100) applies
|
||||
DelegatedAdminAllowed = false,
|
||||
BootstrapIncluded = false,
|
||||
UnrestrictedModeAvailable = false,
|
||||
McpUsagePlanName = CommunityTierName,
|
||||
McpDailyRequestLimit = 100,
|
||||
McpMonthlyRequestLimit = 3000
|
||||
};
|
||||
|
||||
private static readonly EditionEntitlements Enterprise = new()
|
||||
private static readonly EditionEntitlements Pro = new()
|
||||
{
|
||||
Edition = TenantEdition.Enterprise,
|
||||
Edition = TenantEdition.Pro,
|
||||
RetentionCapDays = 365,
|
||||
UserRateLimitPerMinute = 150,
|
||||
DeviceRateLimitPerMinute = 150,
|
||||
DelegatedAdminAllowed = true,
|
||||
McpUsagePlanName = EnterpriseTierName,
|
||||
BootstrapIncluded = true,
|
||||
UnrestrictedModeAvailable = true,
|
||||
McpUsagePlanName = ProTierName,
|
||||
McpDailyRequestLimit = 1000,
|
||||
McpMonthlyRequestLimit = 20000
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when the stored tier is a PERMANENT Pro tier ("pro", or the legacy stored value
|
||||
/// "enterprise"), independent of any trial. Used to separate paid tenants from trial
|
||||
/// tenants (feature-flags isTrial, trial-expiry sweep skip).
|
||||
/// </summary>
|
||||
public static bool IsPermanentProTier(string? planTier)
|
||||
{
|
||||
var trimmed = planTier?.Trim();
|
||||
return string.Equals(trimmed, ProTierName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(trimmed, LegacyEnterpriseTierName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective edition from the stored plan tier and trial expiry.
|
||||
/// Enterprise ⇔ exact tier "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc.
|
||||
/// Pro ⇔ tier "pro"/legacy "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc.
|
||||
/// A trial expiring exactly at <paramref name="nowUtc"/> is already over (strict >).
|
||||
/// </summary>
|
||||
public static TenantEdition ResolveEdition(string? planTier, DateTime? trialExpiresUtc, DateTime nowUtc)
|
||||
{
|
||||
if (string.Equals(planTier?.Trim(), EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
|
||||
return TenantEdition.Enterprise;
|
||||
if (IsPermanentProTier(planTier))
|
||||
return TenantEdition.Pro;
|
||||
|
||||
if (trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc)
|
||||
return TenantEdition.Enterprise;
|
||||
return TenantEdition.Pro;
|
||||
|
||||
return TenantEdition.Community;
|
||||
}
|
||||
|
||||
/// <summary>Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).</summary>
|
||||
public static EditionEntitlements Get(TenantEdition edition) =>
|
||||
edition == TenantEdition.Enterprise ? Enterprise : Community;
|
||||
edition == TenantEdition.Pro ? Pro : Community;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user