mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edf30d35e0 | |||
| 3b4228e3a1 | |||
| a029d69d5e | |||
| daae1ce1fa | |||
| d90cf24508 | |||
| 32f1fd4132 | |||
| ffb09e60fa | |||
| f920b6228e | |||
| 1765773116 | |||
| 5c7295019b | |||
| a0ad769231 | |||
| 64da9f850b | |||
| 5dae21968b | |||
| 59897dfbd8 | |||
| f4551ea1ed | |||
| e816c92b88 | |||
| a16a70fba5 | |||
| 1b2bdd8f35 | |||
| 942bd86238 | |||
| 7a920049ca | |||
| 2eeb4da950 | |||
| 20a80cbdc6 | |||
| c59e65ee0a | |||
| 89aa9e1f10 | |||
| 2d3700cfaf | |||
| 5a182f3f16 | |||
| 889b843d6d | |||
| 9b91119239 | |||
| 1e74d67da9 | |||
| ed013bbdda | |||
| 4b40619d1d | |||
| 3ce1f2b036 | |||
| 673f045212 | |||
| df640e3684 | |||
| 59bea9d888 | |||
| e2c91afda2 | |||
| eb47c4d69a | |||
| 91d56f2bbb | |||
| 12b847360c |
+1
-1
Submodule .claude/commands updated: 24ac014068...88fa1b0274
+1
-1
Submodule internal updated: 5785aaed4c...efec98979a
@@ -29,10 +29,9 @@
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
|
||||
(cert-to-device binding: verifies that the Intune device id carried in the
|
||||
agent's client certificate is a device THIS tenant actually enrolled --
|
||||
currently a Global-Admin-only preview that records telemetry and does not
|
||||
block enrollment)
|
||||
(Intune Enrollment Validation: accepts devices enrolled in THIS tenant's
|
||||
Intune, matched by the Intune device id in the agent's client certificate,
|
||||
without Autopilot registration, corporate identifier or device association)
|
||||
- All -> every optional permission above, in one run
|
||||
(also works with -Revoke to remove all optional grants at once)
|
||||
|
||||
@@ -76,15 +75,17 @@
|
||||
.NOTES
|
||||
Requires:
|
||||
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
|
||||
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
|
||||
- Sign-in as Global Administrator OR Privileged Role Administrator (with PIM, activate the
|
||||
role first). Only these roles may assign Microsoft Graph application permissions;
|
||||
Application Administrator and Cloud Application Administrator are not enough.
|
||||
- The Autopilot Monitor app must be admin-consented in your tenant first
|
||||
(its service principal must exist before grants can be added).
|
||||
|
||||
Sign-in behaviour:
|
||||
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
|
||||
token for the already-signed-in user) - no device-code prompt, which Conditional Access
|
||||
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
|
||||
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
|
||||
- The Autopilot Monitor app must be admin-consented in your tenant first
|
||||
(its service principal must exist before grants can be added).
|
||||
|
||||
After granting:
|
||||
The Autopilot Monitor backend may cache an older token for up to ~1 hour. Use the
|
||||
@@ -140,6 +141,36 @@ if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
|
||||
# ---- Constants ----
|
||||
$MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000'
|
||||
|
||||
# ---- Graph failure details ----
|
||||
# Invoke-MgGraphRequest puts only "Response status code does not indicate success: ..." into the
|
||||
# exception message. Graph's own error (code + message) is the response body in ErrorDetails,
|
||||
# behind the request line, the status line and the headers. Some headers carry JSON themselves
|
||||
# (x-ms-ags-diagnostic), so the body is taken from behind the blank line that ends the headers.
|
||||
function Get-GraphFailure {
|
||||
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
|
||||
|
||||
$status = 0
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
$status = [int]$ErrorRecord.Exception.Response.StatusCode
|
||||
}
|
||||
|
||||
$text = $ErrorRecord.Exception.Message
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$body = ($ErrorRecord.ErrorDetails.Message -split '\r?\n\r?\n', 2)[-1].Trim()
|
||||
if ($body.StartsWith('{')) {
|
||||
try {
|
||||
$graphError = ($body | ConvertFrom-Json).error
|
||||
if ($graphError.code) { $text = "$($graphError.code): $($graphError.message)" }
|
||||
}
|
||||
catch {
|
||||
# Not a Graph JSON body - keep the exception message.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{ Status = $status; Text = $text }
|
||||
}
|
||||
|
||||
# ---- Pre-flight: module ----
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
|
||||
Write-Host "[Setup] Microsoft.Graph.Authentication not installed. Installing for current user..." -ForegroundColor Yellow
|
||||
@@ -189,7 +220,7 @@ Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
|
||||
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
|
||||
# and its effective rights follow the signed-in user's directory role.
|
||||
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
|
||||
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
|
||||
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Administrator or Privileged Role Administrator)."
|
||||
if (-not $VerifyOnly) { return }
|
||||
}
|
||||
|
||||
@@ -263,6 +294,7 @@ $action = if ($Revoke) { 'Revoke' } else { 'Grant' }
|
||||
Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan
|
||||
|
||||
$results = @()
|
||||
$graphForbidden = $false
|
||||
foreach ($permName in $Permissions) {
|
||||
$appRole = $graphSp.appRoles | Where-Object {
|
||||
$_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application'
|
||||
@@ -289,7 +321,9 @@ foreach ($permName in $Permissions) {
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' }
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)"
|
||||
$failure = Get-GraphFailure $_
|
||||
if ($failure.Status -eq 403) { $graphForbidden = $true }
|
||||
Write-Warning " [FAIL] '$permName' revoke failed: $($failure.Text)"
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
|
||||
}
|
||||
}
|
||||
@@ -316,7 +350,9 @@ foreach ($permName in $Permissions) {
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' }
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)"
|
||||
$failure = Get-GraphFailure $_
|
||||
if ($failure.Status -eq 403) { $graphForbidden = $true }
|
||||
Write-Warning " [FAIL] '$permName' grant failed: $($failure.Text)"
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
|
||||
}
|
||||
}
|
||||
@@ -325,6 +361,9 @@ foreach ($permName in $Permissions) {
|
||||
Write-Host ""
|
||||
Write-Host "=========== Summary ===========" -ForegroundColor Cyan
|
||||
$results | Format-Table -AutoSize | Out-Host
|
||||
if ($graphForbidden) {
|
||||
Write-Warning "Microsoft Graph refused the change (403): the signed-in account may not manage Microsoft Graph application permissions. Sign in as Global Administrator or Privileged Role Administrator (with PIM, activate the role first) and run the script again."
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Note: token-cached backends may need up to ~1h to see the new permission set." -ForegroundColor Yellow
|
||||
Write-Host " Use 'Refresh permission status' in the admin UI to apply changes immediately." -ForegroundColor Yellow
|
||||
|
||||
+18
@@ -20,6 +20,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
private sealed class FakeApiClient : BackendApiClient
|
||||
{
|
||||
public readonly List<TimeSpan?> Timeouts = new List<TimeSpan?>();
|
||||
public readonly List<AgentErrorReport> Reports = new List<AgentErrorReport>();
|
||||
public readonly Queue<bool> Results = new Queue<bool>();
|
||||
|
||||
public FakeApiClient() : base(
|
||||
@@ -40,6 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
public override Task<bool> ReportAgentErrorAsync(AgentErrorReport report, TimeSpan? timeout = null)
|
||||
{
|
||||
Timeouts.Add(timeout);
|
||||
Reports.Add(report);
|
||||
return Task.FromResult(Results.Count > 0 && Results.Dequeue());
|
||||
}
|
||||
}
|
||||
@@ -139,5 +141,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_age_is_carried_in_the_report_and_absent_by_default()
|
||||
{
|
||||
// One reporter per report: a delivered report starts the 10-minute cooldown.
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
|
||||
await Reporter(client).TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m", sessionAgeHours: 478.2);
|
||||
await Reporter(client).TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
|
||||
|
||||
Assert.Equal(2, client.Reports.Count);
|
||||
Assert.Equal(478.2, client.Reports[0].SessionAgeHours);
|
||||
Assert.Null(client.Reports[1].SessionAgeHours);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Response parsers of the geo providers. Addresses come from the RFC 5737 documentation
|
||||
/// ranges — never real ones.
|
||||
/// </summary>
|
||||
public class GeoLocationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseDoGeo_maps_country_and_egress_ip_and_ignores_the_do_service_fields()
|
||||
{
|
||||
var json = JObject.Parse(@"{
|
||||
""ExternalIpAddress"": ""203.0.113.7"",
|
||||
""CountryCode"": ""DE"",
|
||||
""KeyValue_EndpointFullUri"": ""https://kv601.prod.do.dsp.mp.microsoft.com/all"",
|
||||
""Version"": ""26A6D4D6994E8DA5"",
|
||||
""CacheId"": ""7"",
|
||||
""ContentCert"": false,
|
||||
""DownloadModeFailSafe"": """"
|
||||
}");
|
||||
|
||||
var result = GeoLocationService.ParseDoGeo(json);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("DE", result.Country);
|
||||
Assert.Equal("203.0.113.7", result.Ip);
|
||||
Assert.Equal("do-geo", result.Source);
|
||||
Assert.Null(result.Region);
|
||||
Assert.Null(result.City);
|
||||
Assert.Null(result.Loc);
|
||||
Assert.Null(result.Timezone);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"" }")]
|
||||
[InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"", ""CountryCode"": """" }")]
|
||||
public void ParseDoGeo_without_country_is_a_failure(string body)
|
||||
{
|
||||
Assert.Null(GeoLocationService.ParseDoGeo(JObject.Parse(body)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseIpInfo_maps_all_fields()
|
||||
{
|
||||
var json = JObject.Parse(@"{
|
||||
""ip"": ""198.51.100.4"", ""city"": ""Stuttgart"", ""region"": ""Baden-Wurttemberg"",
|
||||
""country"": ""DE"", ""loc"": ""48.7823,9.1770"", ""timezone"": ""Europe/Berlin""
|
||||
}");
|
||||
|
||||
var result = GeoLocationService.ParseIpInfo(json);
|
||||
|
||||
Assert.Equal("DE", result.Country);
|
||||
Assert.Equal("Baden-Wurttemberg", result.Region);
|
||||
Assert.Equal("Stuttgart", result.City);
|
||||
Assert.Equal("48.7823,9.1770", result.Loc);
|
||||
Assert.Equal("Europe/Berlin", result.Timezone);
|
||||
Assert.Equal("198.51.100.4", result.Ip);
|
||||
Assert.Equal("ipinfo", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseIfConfigCo_joins_coordinates_and_leaves_loc_empty_without_them()
|
||||
{
|
||||
var withCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{
|
||||
""ip"": ""198.51.100.4"", ""country_iso"": ""DE"", ""region_name"": ""Bavaria"",
|
||||
""city"": ""Munich"", ""latitude"": 48.13, ""longitude"": 11.58, ""time_zone"": ""Europe/Berlin""
|
||||
}"));
|
||||
var withoutCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{ ""country_iso"": ""DE"" }"));
|
||||
|
||||
Assert.Equal("48.13,11.58", withCoords.Loc);
|
||||
Assert.Equal("DE", withCoords.Country);
|
||||
Assert.Equal("Bavaria", withCoords.Region);
|
||||
Assert.Equal("ifconfig.co", withCoords.Source);
|
||||
Assert.Equal("", withoutCoords.Loc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_skips_the_parts_a_provider_did_not_return()
|
||||
{
|
||||
Assert.Equal("Stuttgart, BW, DE", new GeoLocationResult { City = "Stuttgart", Region = "BW", Country = "DE" }.Describe());
|
||||
Assert.Equal("DE", new GeoLocationResult { Country = "DE", Region = "" }.Describe());
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -162,6 +162,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(SessionStage.AwaitingHello, result.InitialState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_snapshot_of_a_rotated_session_adopts_the_current_session_id()
|
||||
{
|
||||
// SESSION-OWNER-BINDING: the backend refused the persisted session id at registration,
|
||||
// the agent rotated, and the snapshot on disk still names the old session. The
|
||||
// enrollment state carries over; the session id must be the current one.
|
||||
using var rig = new Rig();
|
||||
var saved = DecisionState.CreateInitial("S-REFUSED", TenantId)
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(7)
|
||||
.WithLastAppliedSignalOrdinal(6)
|
||||
.Build();
|
||||
SeedSnapshot(rig, saved);
|
||||
|
||||
var result = rig.Recover();
|
||||
|
||||
Assert.Equal(SessionId, result.InitialState.SessionId);
|
||||
Assert.Equal(SessionStage.EspAccountSetup, result.InitialState.Stage);
|
||||
Assert.Equal(7, result.InitialState.StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_plus_pending_tail_replays_tail_onto_snapshot()
|
||||
{
|
||||
|
||||
@@ -149,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-100));
|
||||
|
||||
var stateDir = Path.Combine(tmp.Path, "State");
|
||||
double? reportedAgeHours = null;
|
||||
|
||||
var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
|
||||
dataDirectory: tmp.Path,
|
||||
@@ -157,9 +158,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
selfDestructOnComplete: false,
|
||||
cleanupServiceFactory: null,
|
||||
logger: logger,
|
||||
consoleMode: false);
|
||||
consoleMode: false,
|
||||
onBreakFired: ageHours => reportedAgeHours = ageHours);
|
||||
|
||||
Assert.True(tripped);
|
||||
// The measured age reaches the break report (the backend cannot derive it).
|
||||
Assert.NotNull(reportedAgeHours);
|
||||
Assert.InRange(reportedAgeHours.Value, 99.9, 100.1);
|
||||
// Marker must have been written so the next restart exits cleanly.
|
||||
Assert.True(File.Exists(Path.Combine(stateDir, "enrollment-complete.marker")));
|
||||
// Session must have been cleared.
|
||||
|
||||
+16
@@ -62,6 +62,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
|
||||
Assert.Equal("ipinfo.io", evt.Data["source"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGeoEvent_country_only_location_has_no_empty_parts_in_message()
|
||||
{
|
||||
var loc = new GeoLocationResult { Country = "DE", Source = "do-geo" };
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildGeoEvent(Config(), loc);
|
||||
|
||||
Assert.Equal("Device location: DE (via do-geo)", evt.Message);
|
||||
Assert.Equal("DE", evt.Data["country"]);
|
||||
Assert.Equal("do-geo", evt.Data["source"]);
|
||||
}
|
||||
|
||||
// ================================================================= Outbound IP
|
||||
|
||||
[Fact]
|
||||
@@ -98,6 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
|
||||
PrimaryError = "connection refused",
|
||||
PrimaryRetryError = "DNS failure",
|
||||
FallbackError = "timeout",
|
||||
DoGeoError = "HTTP 403 (Forbidden)",
|
||||
};
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt);
|
||||
@@ -108,6 +121,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
|
||||
Assert.Equal("connection refused", evt.Data["primaryError"]);
|
||||
Assert.Equal("DNS failure", evt.Data["primaryRetryError"]);
|
||||
Assert.Equal("timeout", evt.Data["fallbackError"]);
|
||||
Assert.Equal("HTTP 403 (Forbidden)", evt.Data["doGeoError"]);
|
||||
Assert.Equal("do-geo", evt.Data["lastResortProvider"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
|
||||
Assert.Equal("unknown", evt.Data["primaryError"]);
|
||||
Assert.Equal("unknown", evt.Data["primaryRetryError"]);
|
||||
Assert.Equal("unknown", evt.Data["fallbackError"]);
|
||||
Assert.Equal("unknown", evt.Data["doGeoError"]);
|
||||
}
|
||||
|
||||
// ================================================================= Timezone
|
||||
|
||||
+80
@@ -137,6 +137,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
=> new BackendRefusalException("Backend returned 409 Conflict with error code session_owner_mismatch.", 409,
|
||||
AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch);
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_conflict_409_rotates_once_like_an_owner_mismatch()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw TenantConflict()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { config.SessionId = rotatedTo; return rotatedTo; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coded_refusal_after_rotation_fails_without_retry_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw TenantConflict());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a coded refusal must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotation_hook_failure_on_a_tenant_conflict_fails_without_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw TenantConflict());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"));
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure()
|
||||
{
|
||||
|
||||
+29
@@ -88,6 +88,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Null(ex.ErrorCode);
|
||||
}
|
||||
|
||||
// ── coded 409: a refusal, never an auth failure ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Coded_409_is_a_refusal_not_an_auth_failure()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Conflict,
|
||||
"{\"success\":false,\"message\":\"Session id is already registered to another tenant\",\"errorCode\":\"session_owner_mismatch\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendRefusalException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(409, ex.StatusCode);
|
||||
Assert.Equal("session_owner_mismatch", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uncoded_409_stays_a_plain_http_error()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Conflict,
|
||||
"{\"success\":false,\"message\":\"A diagnostics package with this name already exists\"}",
|
||||
"application/json");
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
@@ -237,6 +237,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
using (response)
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -338,6 +339,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode: errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendRefusalException"/> for a 409 whose JSON body carries a
|
||||
/// machine-readable <c>errorCode</c> — register-session answers
|
||||
/// <c>session_owner_mismatch</c> when another tenant claimed the session id, which no retry
|
||||
/// can clear but a session rotation can. A 409 without a code falls through to
|
||||
/// <c>EnsureSuccessStatusCode</c> as before.
|
||||
/// </summary>
|
||||
private static async Task ThrowOnCodedConflictAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
return;
|
||||
|
||||
string body = null;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// No body, no code — the generic 409 handling applies.
|
||||
}
|
||||
|
||||
var errorCode = TryExtractErrorCode(body);
|
||||
if (errorCode == null)
|
||||
return;
|
||||
|
||||
throw new BackendRefusalException(
|
||||
$"Backend returned 409 Conflict with error code {errorCode}.",
|
||||
(int)response.StatusCode,
|
||||
errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
@@ -471,4 +504,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the backend refuses a request on a non-auth status (409) with a machine-readable
|
||||
/// code from Shared <c>Constants.AgentErrorCodes</c>. Unlike <see cref="BackendAuthException"/>
|
||||
/// it says nothing about the device's authorization and must never feed the auth-failure tracker.
|
||||
/// </summary>
|
||||
public class BackendRefusalException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (409).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
/// <summary>Machine-readable code from the backend's JSON body (never null).</summary>
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public BackendRefusalException(string message, int statusCode, string errorCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// exist for callers whose process is about to exit and cannot come back later
|
||||
/// (emergency break): retries stay INSIDE the one reservation, so they never
|
||||
/// multiply against the per-session budget.
|
||||
///
|
||||
/// <paramref name="sessionAgeHours"/> is the emergency break's measured session age
|
||||
/// (<see cref="AgentErrorReport.SessionAgeHours"/>); null for every other error type.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
@@ -87,7 +90,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null)
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null)
|
||||
{
|
||||
// Deduplicate by error type + status code: same failure category sent only once per session.
|
||||
// All anti-flood checks are protected by a lock to prevent race conditions when
|
||||
@@ -135,6 +139,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
SequenceNumber = sequenceNumber,
|
||||
SessionAgeHours = sessionAgeHours,
|
||||
AgentVersion = _agentVersion,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
+87
-68
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -25,6 +26,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// </summary>
|
||||
public string Ip { get; set; }
|
||||
|
||||
/// <summary>"City, Region, Country" without the parts a provider did not return.</summary>
|
||||
public string Describe() =>
|
||||
string.Join(", ", new[] { City, Region, Country }.Where(p => !string.IsNullOrEmpty(p)));
|
||||
|
||||
public Dictionary<string, object> ToDictionary()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
@@ -45,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
public string PrimaryError { get; set; }
|
||||
public string PrimaryRetryError { get; set; }
|
||||
public string FallbackError { get; set; }
|
||||
public string DoGeoError { get; set; }
|
||||
}
|
||||
|
||||
public static class GeoLocationService
|
||||
@@ -52,6 +58,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
internal const string IpInfoUrl = "https://ipinfo.io/json";
|
||||
internal const string IfConfigCoUrl = "https://ifconfig.co/json";
|
||||
|
||||
/// <summary>
|
||||
/// Delivery Optimization's own geo endpoint. Country and egress IP only (no region, city,
|
||||
/// coordinates or timezone), but it is part of the Windows Update / DO endpoint set that
|
||||
/// Autopilot needs anyway, so it stays reachable in networks that block public geo services.
|
||||
/// </summary>
|
||||
internal const string DoGeoUrl = "https://geo.prod.do.dsp.mp.microsoft.com/geo";
|
||||
|
||||
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
|
||||
// HttpClient is designed to be reused across requests.
|
||||
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
|
||||
@@ -68,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
var attempt = new GeoLocationAttemptResult();
|
||||
|
||||
// Try ipinfo.io first
|
||||
var (result, error) = await TryIpInfo(logger);
|
||||
var (result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
@@ -80,7 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s...");
|
||||
await Task.Delay(RetryDelay);
|
||||
|
||||
(result, error) = await TryIpInfo(logger);
|
||||
(result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
@@ -89,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
attempt.PrimaryRetryError = error;
|
||||
|
||||
// Fallback to ifconfig.co
|
||||
(result, error) = await TryIfConfigCo(logger);
|
||||
(result, error) = await TryProvider("ifconfig.co", IfConfigCoUrl, ParseIfConfigCo, logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
@@ -97,108 +113,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
}
|
||||
attempt.FallbackError = error;
|
||||
|
||||
// Last resort: Delivery Optimization geo — country only, but better than no location
|
||||
(result, error) = await TryProvider("do-geo", DoGeoUrl, ParseDoGeo, logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
return attempt;
|
||||
}
|
||||
attempt.DoGeoError = error;
|
||||
|
||||
logger?.Warning("GeoLocation: All providers failed, skipping location event");
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger)
|
||||
private static async Task<(GeoLocationResult result, string error)> TryProvider(
|
||||
string name, string url, Func<JObject, GeoLocationResult> parse, AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Info("GeoLocation: Querying ipinfo.io...");
|
||||
logger?.Info($"GeoLocation: Querying {name}...");
|
||||
|
||||
using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
|
||||
using (var httpResponse = await SharedHttpClient.GetAsync(url))
|
||||
{
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
logger?.Warning($"GeoLocation: {name} failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
var response = await httpResponse.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var result = new GeoLocationResult
|
||||
var result = parse(JObject.Parse(response));
|
||||
if (result == null)
|
||||
{
|
||||
Country = json.Value<string>("country"),
|
||||
Region = json.Value<string>("region"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = json.Value<string>("loc"),
|
||||
Timezone = json.Value<string>("timezone"),
|
||||
Ip = json.Value<string>("ip"),
|
||||
Source = "ipinfo"
|
||||
};
|
||||
var error = "No country in response";
|
||||
logger?.Warning($"GeoLocation: {name} failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
|
||||
logger?.Info($"GeoLocation: {name} returned {result.Describe()}");
|
||||
return (result, null);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
var error = "Timeout (5s)";
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
var error = $"Timeout ({RequestTimeout.TotalSeconds}s)";
|
||||
logger?.Warning($"GeoLocation: {name} failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = ex.Message;
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
logger?.Warning($"GeoLocation: {name} failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger)
|
||||
internal static GeoLocationResult ParseIpInfo(JObject json) =>
|
||||
new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country"),
|
||||
Region = json.Value<string>("region"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = json.Value<string>("loc"),
|
||||
Timezone = json.Value<string>("timezone"),
|
||||
Ip = json.Value<string>("ip"),
|
||||
Source = "ipinfo"
|
||||
};
|
||||
|
||||
internal static GeoLocationResult ParseIfConfigCo(JObject json)
|
||||
{
|
||||
try
|
||||
var latitude = json.Value<string>("latitude") ?? "";
|
||||
var longitude = json.Value<string>("longitude") ?? "";
|
||||
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
|
||||
? $"{latitude},{longitude}"
|
||||
: "";
|
||||
|
||||
return new GeoLocationResult
|
||||
{
|
||||
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
|
||||
Country = json.Value<string>("country_iso"),
|
||||
Region = json.Value<string>("region_name"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = loc,
|
||||
Timezone = json.Value<string>("time_zone"),
|
||||
Ip = json.Value<string>("ip"),
|
||||
Source = "ifconfig.co"
|
||||
};
|
||||
}
|
||||
|
||||
using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
|
||||
{
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
/// <summary>
|
||||
/// The DO geo response carries DO service configuration next to the geo fields; only
|
||||
/// <c>CountryCode</c> and <c>ExternalIpAddress</c> are location data. Without a country
|
||||
/// the response is worthless as a location, so it counts as a failure (null).
|
||||
/// </summary>
|
||||
internal static GeoLocationResult ParseDoGeo(JObject json)
|
||||
{
|
||||
var country = json.Value<string>("CountryCode");
|
||||
if (string.IsNullOrEmpty(country))
|
||||
return null;
|
||||
|
||||
var response = await httpResponse.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var latitude = json.Value<string>("latitude") ?? "";
|
||||
var longitude = json.Value<string>("longitude") ?? "";
|
||||
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
|
||||
? $"{latitude},{longitude}"
|
||||
: "";
|
||||
|
||||
var result = new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country_iso"),
|
||||
Region = json.Value<string>("region_name"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = loc,
|
||||
Timezone = json.Value<string>("time_zone"),
|
||||
Ip = json.Value<string>("ip"),
|
||||
Source = "ifconfig.co"
|
||||
};
|
||||
|
||||
logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
|
||||
return (result, null);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
return new GeoLocationResult
|
||||
{
|
||||
var error = "Timeout (5s)";
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = ex.Message;
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
Country = country,
|
||||
Ip = json.Value<string>("ExternalIpAddress"),
|
||||
Source = "do-geo"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Re-stamp AgentBootUtc so the current run's deadlines floor at "now" — the
|
||||
// persisted boot anchor is from the prior run and using it would let replayed
|
||||
// tail signals arm deadlines that are already past-due (premature fire).
|
||||
seed = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc).Build();
|
||||
// Re-stamp SessionId too: after a session rotation at registration
|
||||
// (SESSION-OWNER-BINDING) the snapshot still names the refused session, and the
|
||||
// timeline emitter takes each event's SessionId from this state.
|
||||
var seedBuilder = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc);
|
||||
seedBuilder.SessionId = sessionId;
|
||||
seed = seedBuilder.Build();
|
||||
signalsToReplay = CollectSignalLogTailAfter(signalLog, loadedState.LastAppliedSignalOrdinal);
|
||||
branchTag = signalsToReplay.Count > 0 ? "b-tail-replay" : "b-snapshot-current";
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>device_location</b> (or <c>agent_trace</c> on failure) — IP-based geo via
|
||||
/// <see cref="GeoLocationService"/>. Skipped when <c>EnableGeoLocation=false</c>.</item>
|
||||
/// <see cref="GeoLocationService"/>. Skipped when <c>EnableGeoLocation=false</c>. The
|
||||
/// last-resort provider (DO geo) yields a country-only location without timezone.</item>
|
||||
/// <item><b>timezone_auto_set</b> — only when <c>EnableTimezoneAutoSet=true</c> AND the
|
||||
/// geo lookup returned an IANA timezone. Uses <see cref="TimezoneService"/> (tzutil).</item>
|
||||
/// <item><b>ntp_time_check</b> — NTP offset from <c>NtpServer</c> (default time.windows.com).
|
||||
@@ -225,7 +226,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
Source = "StartupEnvironmentProbes",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
|
||||
Message = $"Device location: {location.Describe()} (via {location.Source})",
|
||||
Data = location.ToDictionary(),
|
||||
ImmediateUpload = true,
|
||||
};
|
||||
@@ -266,8 +267,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
{ "primaryError", attempt?.PrimaryError ?? "unknown" },
|
||||
{ "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" },
|
||||
{ "fallbackError", attempt?.FallbackError ?? "unknown" },
|
||||
{ "doGeoError", attempt?.DoGeoError ?? "unknown" },
|
||||
{ "primaryProvider", "ipinfo.io" },
|
||||
{ "fallbackProvider", "ifconfig.co" },
|
||||
{ "lastResortProvider", "do-geo" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -34,13 +34,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
|
||||
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
|
||||
/// without retrying — the backend has definitively rejected the device cert.</item>
|
||||
/// <item>Exception to the above — a 403 carrying error code
|
||||
/// <item>Exception to the above — a 403 or 409 carrying error code
|
||||
/// <c>session_owner_mismatch</c> (SESSION-OWNER-BINDING): the persisted SessionId names a
|
||||
/// session bound to another device identity (typically an Intune re-enrollment without a
|
||||
/// wipe, where <c>session.id</c> survived but the certificate identity changed). That is
|
||||
/// not an auth failure: the session is rotated through <c>rotateSession</c> and
|
||||
/// registration retried exactly once. The tracker is NOT fed — five of these would
|
||||
/// otherwise soft-shutdown a perfectly authorized agent.</item>
|
||||
/// session that is not this device's — bound to another device identity (403, typically an
|
||||
/// Intune re-enrollment without a wipe, where <c>session.id</c> survived but the certificate
|
||||
/// identity changed) or claimed by another tenant (409). That is not an auth failure: the
|
||||
/// session is rotated through <c>rotateSession</c> and registration retried exactly once.
|
||||
/// The tracker is NOT fed — five of these would otherwise soft-shutdown a perfectly
|
||||
/// authorized agent.</item>
|
||||
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -113,14 +116,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
logger.Warning($"Session registration failed: {lastError}");
|
||||
}
|
||||
catch (BackendAuthException ex) when (
|
||||
ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch
|
||||
catch (Exception ex) when (
|
||||
IsSessionOwnerMismatch(ex)
|
||||
&& rotateSession != null
|
||||
&& rotatedFromSessionId == null)
|
||||
{
|
||||
// SESSION-OWNER-BINDING: the id on disk belongs to a session this device
|
||||
// identity does not own. Become a new session and register once more —
|
||||
// immediately, no backoff, no auth-failure bookkeeping.
|
||||
// SESSION-OWNER-BINDING: the id on disk names a session this device does not
|
||||
// own. Become a new session and register once more — immediately, no backoff,
|
||||
// no auth-failure bookkeeping.
|
||||
rotatedFromSessionId = registration.SessionId;
|
||||
string newSessionId;
|
||||
try
|
||||
@@ -130,15 +133,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Session owner mismatch reported by backend but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
|
||||
if (ex is BackendAuthException authEx)
|
||||
{
|
||||
authFailureTracker?.RecordFailure(authEx.StatusCode, "agent/register-session", authEx.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(authEx.StatusCode, authEx.Message);
|
||||
}
|
||||
return SessionRegistrationResult.Failed(ex.Message);
|
||||
}
|
||||
logger.Warning(
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is bound to another device identity (re-enrollment without wipe?). Rotated to {newSessionId}; re-registering.");
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
return SessionRegistrationResult.Failed(ex.Message);
|
||||
}
|
||||
catch (BackendAuthException ex)
|
||||
{
|
||||
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
|
||||
@@ -195,6 +208,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded");
|
||||
}
|
||||
|
||||
private static bool IsSessionOwnerMismatch(Exception ex)
|
||||
{
|
||||
var code = (ex as BackendAuthException)?.ErrorCode ?? (ex as BackendRefusalException)?.ErrorCode;
|
||||
return code == Constants.AgentErrorCodes.SessionOwnerMismatch;
|
||||
}
|
||||
|
||||
/// <summary>V1 parity exponential backoff: 2^attempt seconds.</summary>
|
||||
private static Task DefaultBackoffDelay(int attempt)
|
||||
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
|
||||
|
||||
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
Func<CleanupService> cleanupServiceFactory,
|
||||
AgentLogger logger,
|
||||
bool consoleMode,
|
||||
Action onBreakFired = null)
|
||||
Action<double> onBreakFired = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -347,7 +347,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// (tasks/enrollment-status-reclassification.md). Fired BEFORE cleanup, while the
|
||||
// session state and network are still intact. Never throws — a send failure (e.g. no
|
||||
// network) must not block the cleanup/exit that is the whole point of this guard.
|
||||
try { onBreakFired?.Invoke(); }
|
||||
try { onBreakFired?.Invoke(sessionAgeHours); }
|
||||
catch (Exception cbEx) { logger.Debug($"Emergency-break notify callback failed: {cbEx.Message}"); }
|
||||
|
||||
// Write enrollment-complete.marker so the next start exits cleanly even if
|
||||
|
||||
@@ -87,7 +87,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
cleanupServiceFactory, logger, consoleMode,
|
||||
// Best-effort: surface the otherwise-silent 48h break to the backend before cleanup
|
||||
// (tasks/enrollment-status-reclassification.md). Never blocks the exit.
|
||||
onBreakFired: () => EmergencyBreakReporter.TrySend(agentConfig, Program.GetAgentVersion(), logger)))
|
||||
onBreakFired: sessionAgeHours => EmergencyBreakReporter.TrySend(
|
||||
agentConfig, Program.GetAgentVersion(), sessionAgeHours, logger)))
|
||||
{
|
||||
logger.Info("Emergency break fired — agent exiting.");
|
||||
return BootstrapResult.Exit(0);
|
||||
|
||||
@@ -70,8 +70,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// ours" into a new session. Rotates the persisted id, points the runtime config at it,
|
||||
/// drops the transport spool (its lines carry the refused session's partition key and
|
||||
/// would otherwise be uploaded against it after the rotation) and re-targets the
|
||||
/// emergency reporter, which snapshotted the id before registration. Everything else
|
||||
/// reads <c>agentConfig.SessionId</c> live after Phase 6.
|
||||
/// emergency reporter, which snapshotted the id before registration. Everything else that
|
||||
/// holds a session id (orchestrator, emitters, spool) is built in Phase 7 from the rotated
|
||||
/// <c>agentConfig.SessionId</c> — keep it that way: a component built before Phase 6 needs
|
||||
/// its own re-target here.
|
||||
/// </summary>
|
||||
internal static string RotateSession(
|
||||
AgentConfiguration agentConfig,
|
||||
|
||||
@@ -36,15 +36,18 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Upper bound for the blocking wait: NIC wait + 3×15s attempts + 2×5s delays + slack.
|
||||
private static readonly TimeSpan OverallBudget = TimeSpan.FromSeconds(75);
|
||||
|
||||
public static void TrySend(AgentConfiguration agentConfig, string agentVersion, AgentLogger logger)
|
||||
public static void TrySend(AgentConfiguration agentConfig, string agentVersion, double sessionAgeHours, AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
WaitForNetwork(logger);
|
||||
|
||||
var auth = BackendClientFactory.BuildAuthClients(agentConfig, agentVersion, logger);
|
||||
var message =
|
||||
$"Agent absolute session-age emergency break fired (cap {agentConfig.AbsoluteMaxSessionHours}h) — cleaning up and exiting.";
|
||||
// Invariant: the text lands in the backend's ops feed and timeline; a device locale
|
||||
// would otherwise print "478,2h".
|
||||
var roundedAgeHours = Math.Round(sessionAgeHours, 1);
|
||||
var message = FormattableString.Invariant(
|
||||
$"Agent absolute session-age emergency break fired at session age {roundedAgeHours:F1}h (cap {agentConfig.AbsoluteMaxSessionHours}h) — cleaning up and exiting.");
|
||||
|
||||
// The process is about to exit, so block on the otherwise fire-and-forget send
|
||||
// rather than let it be abandoned mid-flight. TrySendAsync swallows its own HTTP
|
||||
@@ -56,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
message,
|
||||
attempts: SendAttempts,
|
||||
perAttemptTimeout: PerAttemptTimeout,
|
||||
retryDelay: RetryDelay)
|
||||
retryDelay: RetryDelay,
|
||||
sessionAgeHours: roundedAgeHours)
|
||||
.Wait(OverallBudget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
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 Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// D-285: the admin configuration row is changed only through the changed-columns write — fresh
|
||||
/// read, mutation on a copy, only the columns whose value changed are laid onto the freshly read
|
||||
/// row, conditional on its ETag. Pins the 2026-09-25 incident: a Global Settings page loaded before
|
||||
/// an agent release saved one toggle and reverted the release's hashes with the full-model PUT.
|
||||
/// </summary>
|
||||
public class AdminConfigurationChangedFieldsTests
|
||||
{
|
||||
private const string ReleaseVersion = "2.0.1465";
|
||||
private const string ReleaseZipSha = "9f0b5403da7e49202274fdf8f38675bc5cfea85f7d881e85d5fe4bd71308250e";
|
||||
private const string ReleaseExeSha = "503dd02b3c3690acfe20c44dfce82c367ca1e56dcedaba3ecc91ff6ce893d726";
|
||||
|
||||
/// <summary>
|
||||
/// A one-row table that behaves like storage: reads hand out copies with the current ETag,
|
||||
/// a replace with a stale ETag fails 412, a create on an existing row fails 409.
|
||||
/// </summary>
|
||||
private sealed class FakeAdminTable
|
||||
{
|
||||
private int _version = 1;
|
||||
public TableEntity? Current;
|
||||
public int Writes;
|
||||
public TableUpdateMode? LastMode;
|
||||
/// <summary>Runs once, right before the first replace is checked — a concurrent writer.</summary>
|
||||
public Action<FakeAdminTable>? BeforeFirstReplace;
|
||||
public TableConfigRepository Sut { get; }
|
||||
|
||||
public FakeAdminTable(TableEntity? current)
|
||||
{
|
||||
Current = current;
|
||||
var table = new Mock<TableClient>();
|
||||
table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Current == null
|
||||
? throw new RequestFailedException(404, "Not Found", "ResourceNotFound", null)
|
||||
: Task.FromResult(Response.FromValue(Copy(Current, _version), Mock.Of<Response>())));
|
||||
table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, ifMatch, mode, _) =>
|
||||
{
|
||||
if (BeforeFirstReplace != null)
|
||||
{
|
||||
var concurrent = BeforeFirstReplace;
|
||||
BeforeFirstReplace = null;
|
||||
concurrent(this);
|
||||
}
|
||||
if (ifMatch != Etag(_version))
|
||||
throw new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null);
|
||||
Current = Copy(e, _version);
|
||||
_version++;
|
||||
Writes++;
|
||||
LastMode = mode;
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
if (Current != null)
|
||||
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
|
||||
Current = Copy(e, _version);
|
||||
Writes++;
|
||||
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),
|
||||
new Mock<IConfigBackupRepository>().Object,
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>A write by someone else (the release pipeline's Merge), bumping the ETag.</summary>
|
||||
public void ExternalMerge(string column, object value)
|
||||
{
|
||||
Current![column] = value;
|
||||
_version++;
|
||||
}
|
||||
|
||||
private static ETag Etag(int version) => new($"W/\"v{version}\"");
|
||||
|
||||
private static TableEntity Copy(TableEntity source, int version)
|
||||
=> new(source.Where(kv => kv.Key != "odata.etag").ToDictionary(kv => kv.Key, kv => kv.Value)) { ETag = Etag(version) };
|
||||
}
|
||||
|
||||
/// <summary>The stored row right after the agent release: the pipeline's hashes plus a column this build does not know.</summary>
|
||||
private static TableEntity ReleasedRow()
|
||||
{
|
||||
var row = TableConfigRepository.ConvertToAdminTableEntity(new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
NvdApiKey = "stored-key",
|
||||
LatestAgentV2Version = ReleaseVersion,
|
||||
LatestAgentV2Sha256 = ReleaseZipSha,
|
||||
LatestAgentV2ExeSha256 = ReleaseExeSha,
|
||||
});
|
||||
row["ColumnOfANewerBuild"] = "keep-me";
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_page_toggle_writes_only_that_field_and_keeps_the_release_hashes()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
// The page was loaded before the release; it sends only what the operator changed.
|
||||
var fields = JObject.Parse("{ \"enforceClientAppBinding\": true }");
|
||||
Assert.Null(PatchAdminConfigurationFunction.CheckFields(fields));
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
draft => PatchAdminConfigurationFunction.ApplyFields(draft, fields), "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(new[] { "EnforceClientAppBinding" }, result.ChangedColumns);
|
||||
var row = table.Current!;
|
||||
Assert.True(row.GetBoolean("EnforceClientAppBinding"));
|
||||
Assert.Equal(ReleaseVersion, row.GetString("LatestAgentV2Version"));
|
||||
Assert.Equal(ReleaseZipSha, row.GetString("LatestAgentV2Sha256"));
|
||||
Assert.Equal(ReleaseExeSha, row.GetString("LatestAgentV2ExeSha256"));
|
||||
Assert.Equal("keep-me", row.GetString("ColumnOfANewerBuild"));
|
||||
Assert.Equal("stored-key", row.GetString("NvdApiKey"));
|
||||
Assert.Equal(TableUpdateMode.Replace, table.LastMode);
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_concurrent_pipeline_write_is_reread_and_kept()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow())
|
||||
{
|
||||
// The release pipeline lands between the backend's read and its write.
|
||||
BeforeFirstReplace = t => t.ExternalMerge("LatestAgentV2Version", "2.0.1466"),
|
||||
};
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 250; return null; }, "System (test)");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("2.0.1466", table.Current!.GetString("LatestAgentV2Version"));
|
||||
Assert.Equal(250, table.Current.GetInt32("GlobalRateLimitRequestsPerMinute"));
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mutation_that_changes_nothing_writes_nothing()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 100; return null; }, "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Empty(result.ChangedColumns);
|
||||
Assert.Equal(0, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_mutation_writes_nothing()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 999; return "nope"; }, "ga@operator.example");
|
||||
|
||||
Assert.Equal("nope", result.Error);
|
||||
Assert.Equal(0, table.Writes);
|
||||
Assert.Equal(100, table.Current!.GetInt32("GlobalRateLimitRequestsPerMinute"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_null_value_drops_its_column()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.NvdApiKey = null!; return null; }, "ga@operator.example"); // what a JSON null in a patch does
|
||||
|
||||
Assert.Equal(new[] { "NvdApiKey" }, result.ChangedColumns);
|
||||
Assert.False(table.Current!.ContainsKey("NvdApiKey"));
|
||||
Assert.Equal(ReleaseExeSha, table.Current.GetString("LatestAgentV2ExeSha256"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_row_the_first_change_creates_it()
|
||||
{
|
||||
var table = new FakeAdminTable(current: null);
|
||||
|
||||
var result = await table.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.EnforceClientAppBinding = true; return null; }, "ga@operator.example");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(table.Current!.GetBoolean("EnforceClientAppBinding"));
|
||||
Assert.Equal(1, table.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_creation_never_replaces_an_existing_row()
|
||||
{
|
||||
var table = new FakeAdminTable(ReleasedRow());
|
||||
|
||||
var created = await table.Sut.CreateAdminConfigurationIfMissingAsync(AdminConfiguration.CreateDefault());
|
||||
|
||||
Assert.False(created);
|
||||
Assert.Equal(ReleaseVersion, table.Current!.GetString("LatestAgentV2Version"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("latestAgentV2Version")]
|
||||
[InlineData("LatestAgentV2Sha256")]
|
||||
[InlineData("latestAgentV2ExeSha256")]
|
||||
[InlineData("latestBootstrapV2ScriptVersion")]
|
||||
[InlineData("updatedBy")]
|
||||
[InlineData("lastUpdated")]
|
||||
[InlineData("planTierDefinitionsJson")]
|
||||
[InlineData("msrcLastSyncUtc")]
|
||||
public void The_gate_rejects_pipeline_stamp_and_flow_owned_fields(string field)
|
||||
{
|
||||
var error = PatchAdminConfigurationFunction.CheckFields(new JObject { [field] = "x" });
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("not writable", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_gate_rejects_unknown_fields_and_redacted_placeholders()
|
||||
{
|
||||
Assert.Contains("Unknown field", PatchAdminConfigurationFunction.CheckFields(new JObject { ["noSuchField"] = 1 }));
|
||||
Assert.Contains("placeholder", PatchAdminConfigurationFunction.CheckFields(
|
||||
new JObject { ["nvdApiKey"] = Constants.RedactedSecretPlaceholder }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patchable_fields_are_the_persisted_settings()
|
||||
{
|
||||
var patchable = PatchAdminConfigurationFunction.PatchableFields;
|
||||
Assert.Contains("EnforceClientAppBinding", patchable);
|
||||
Assert.Contains("McpClientRegistrationEnabled", patchable);
|
||||
Assert.Contains("McpAccessPolicy", patchable);
|
||||
Assert.Contains("NvdApiKey", patchable);
|
||||
Assert.Contains("OpsNotificationChannelsJson", patchable);
|
||||
Assert.DoesNotContain("LatestAgentV2ExeSha256", patchable);
|
||||
Assert.DoesNotContain("PartitionKey", patchable);
|
||||
Assert.DoesNotContain("UpdatedBy", patchable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Applying_validates_the_result_without_echoing_values()
|
||||
{
|
||||
var draft = AdminConfiguration.CreateDefault();
|
||||
Assert.Contains("at least 1", PatchAdminConfigurationFunction.ApplyFields(
|
||||
draft, JObject.Parse("{ \"globalRateLimitRequestsPerMinute\": 0 }")));
|
||||
|
||||
var typeError = PatchAdminConfigurationFunction.ApplyFields(
|
||||
AdminConfiguration.CreateDefault(), JObject.Parse("{ \"collectorIdleTimeoutMinutes\": \"https://secret.example/sas?sig=abc\" }"));
|
||||
Assert.NotNull(typeError);
|
||||
Assert.DoesNotContain("secret.example", typeError);
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// for the device path and UserRateLimitMiddleware for the user path). These tests pin the
|
||||
/// removal of the former background sync job:
|
||||
/// <list type="bullet">
|
||||
/// <item>Saving the admin config must NOT enumerate or write ANY tenant configuration.</item>
|
||||
/// <item>Changing the admin config must NOT enumerate or write ANY tenant configuration.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Historical context: the sync used to copy <c>GlobalRateLimitRequestsPerMinute</c> into every
|
||||
@@ -28,30 +28,35 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class AdminConfigurationRateLimitSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs()
|
||||
public async Task UpdateAsync_DoesNotEnumerateOrWriteTenantConfigs()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
|
||||
.ReturnsAsync(true);
|
||||
repo.Setup(r => r.UpdateAdminConfigurationAsync(
|
||||
It.IsAny<Func<AdminConfiguration, string?>>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new AdminConfigurationUpdateResult
|
||||
{
|
||||
Before = new AdminConfiguration(),
|
||||
After = new AdminConfiguration(),
|
||||
ChangedColumns = new[] { "GlobalRateLimitRequestsPerMinute", "UserRateLimitRequestsPerMinute" },
|
||||
});
|
||||
|
||||
var sut = new AdminConfigurationService(
|
||||
repo.Object,
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await sut.SaveConfigurationAsync(new AdminConfiguration
|
||||
await sut.UpdateAsync(c =>
|
||||
{
|
||||
UpdatedBy = "global-admin@contoso.com",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
UserRateLimitRequestsPerMinute = 240,
|
||||
});
|
||||
c.GlobalRateLimitRequestsPerMinute = 200;
|
||||
c.UserRateLimitRequestsPerMinute = 240;
|
||||
return null;
|
||||
}, "global-admin@contoso.com");
|
||||
|
||||
// The admin config row is written...
|
||||
repo.Verify(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()), Times.Once);
|
||||
repo.Verify(r => r.UpdateAdminConfigurationAsync(
|
||||
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
|
||||
// ...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);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
@@ -32,6 +33,7 @@ public class AppHomingServiceTests
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GraphTokenService> _graphTokenMock;
|
||||
private readonly Mock<IGraphFeatureDetector> _detectorMock;
|
||||
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
|
||||
@@ -99,13 +101,10 @@ public class AppHomingServiceTests
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
|
||||
|
||||
// Defaults: flag on, legacy-homed config exists, both apps mint a role-less token (the
|
||||
// superset rule passes trivially). FlipAsync reads via the cache-bypassing
|
||||
// GetConfigurationFreshAsync (read-modify-write on the whole entity).
|
||||
// superset rule passes trivially). FlipAsync writes through UpdateAsync (conditional
|
||||
// write on the fresh row), stubbed over one stored row.
|
||||
_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);
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, LegacyHomedConfig());
|
||||
SetupProbe(GraphTokenResult.Success(Jwt()));
|
||||
SetupLegacyToken(GraphTokenResult.Success(Jwt()));
|
||||
}
|
||||
@@ -143,8 +142,7 @@ public class AppHomingServiceTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
private void VerifyNeverSaved() =>
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
private void VerifyNeverSaved() => Assert.Empty(_store.Writes);
|
||||
|
||||
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
|
||||
|
||||
@@ -340,9 +338,10 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.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);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal(PrimaryId, written.HomedAppClientId, ignoreCase: true);
|
||||
Assert.Equal(Actor, written.UpdatedBy);
|
||||
Assert.Equal("app-homing", source);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
|
||||
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
|
||||
@@ -359,7 +358,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -370,7 +369,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -394,7 +393,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
// ── FlipAsync ───────────────────────────────────────────────────────────
|
||||
@@ -404,7 +403,7 @@ public class AppHomingServiceTests
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_store.Row = config;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
@@ -417,11 +416,11 @@ public class AppHomingServiceTests
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_store.Row = 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);
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
@@ -430,30 +429,68 @@ public class AppHomingServiceTests
|
||||
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);
|
||||
// a missing row is a hard stop.
|
||||
_store.Row = 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);
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_decides_on_the_stored_row_not_the_callers_view()
|
||||
{
|
||||
// The caller's (cached) view is legacy-homed; the stored row was flipped meanwhile.
|
||||
var stored = LegacyHomedConfig();
|
||||
stored.HomedAppClientId = PrimaryId;
|
||||
_store.Row = stored;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "consent-auto-flip");
|
||||
|
||||
VerifyNeverSaved();
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_skips_a_tenant_being_offboarded()
|
||||
{
|
||||
var tombstone = LegacyHomedConfig();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_throws_when_every_conditional_write_lost()
|
||||
{
|
||||
_store.Force = TenantConfigUpdateStatus.Conflict;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
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);
|
||||
_store.Row = 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);
|
||||
Assert.Null(Assert.Single(_store.Writes).Row.HomedAppClientId);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -306,7 +306,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
};
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
var stats = root.GetProperty("summary");
|
||||
@@ -325,7 +325,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
|
||||
@@ -195,6 +195,37 @@ public class AppVersionRegressionRadarTests
|
||||
Assert.Empty(AppVersionRegressionRadar.Evaluate(rows2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SameNameInTwoChannels_IsTwoApps()
|
||||
{
|
||||
// The RealmJoin package regressed; the Intune app of the same name did not. A shared
|
||||
// group would dilute the RealmJoin medians with the Intune rows and hide the regression.
|
||||
var rows = new List<AppInstallSummary>();
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
|
||||
rows.AddRange(Batch("1.0", 30, 300, Now.AddDays(-20)));
|
||||
rows.AddRange(Batch("2.0", 30, 300, Now.AddDays(-5)));
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal(AppInstallSources.RealmJoin, finding.Source);
|
||||
Assert.Equal(App, finding.AppName);
|
||||
Assert.Equal(900, finding.CurrentMedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_ReadsTheAlertsOwnChannel()
|
||||
{
|
||||
// The IME app still regresses; the RealmJoin episode's version has no measured installs.
|
||||
var rows = new List<AppInstallSummary>();
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)));
|
||||
|
||||
var rjAlert = Alert();
|
||||
rjAlert.Source = AppInstallSources.RealmJoin;
|
||||
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, rjAlert));
|
||||
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal()
|
||||
{
|
||||
@@ -279,13 +310,25 @@ public class AppVersionRegressionRadarTests
|
||||
{
|
||||
Assert.Equal("appversionregression|contoso app_2|1.0_beta_",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(
|
||||
" Contoso App/2 ", " 1.0#Beta? "));
|
||||
AppInstallSources.Ime, " Contoso App/2 ", " 1.0#Beta? "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_ImeKeepsPreChannelShape_OtherChannelsPrefixTheApp()
|
||||
{
|
||||
// Episodes written before channels existed must keep resolving (re-arm deletes them by key).
|
||||
Assert.Equal("appversionregression|contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(null, App, "2.0"));
|
||||
Assert.Equal("appversionregression|contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(string.Empty, App, "2.0"));
|
||||
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "2.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces()
|
||||
{
|
||||
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0");
|
||||
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "1.0");
|
||||
Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal);
|
||||
// The prefix range [prefix, "appversionregression}") must not swallow the sibling
|
||||
// keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}").
|
||||
@@ -293,6 +336,39 @@ public class AppVersionRegressionRadarTests
|
||||
Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RealmJoinChannel_RoundTripsWithPrefixedKey()
|
||||
{
|
||||
var alert = new AppVersionRegressionAlert
|
||||
{
|
||||
TenantId = TenantA,
|
||||
AppName = App,
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
CurrentVersion = "2.4.0",
|
||||
PreviousVersion = "2.3.9",
|
||||
FirstNotifiedAt = Now,
|
||||
LastEvaluatedAt = Now,
|
||||
};
|
||||
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert);
|
||||
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.4.0", entity.RowKey);
|
||||
Assert.Equal(AppInstallSources.RealmJoin,
|
||||
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RowWithoutSourceColumn_MapsToIme()
|
||||
{
|
||||
var entity = new Azure.Data.Tables.TableEntity(TenantA.ToLowerInvariant(), "appversionregression|contoso vpn|2.4.0")
|
||||
{
|
||||
["TenantId"] = TenantA,
|
||||
["AppName"] = App,
|
||||
["CurrentVersion"] = "2.4.0",
|
||||
};
|
||||
Assert.Equal(AppInstallSources.Ime,
|
||||
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RoundTripsAllFields()
|
||||
{
|
||||
@@ -318,6 +394,7 @@ public class AppVersionRegressionRadarTests
|
||||
var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal(App, mapped.AppName);
|
||||
Assert.Equal(AppInstallSources.Ime, mapped.Source);
|
||||
Assert.Equal("2.4.0", mapped.CurrentVersion);
|
||||
Assert.Equal("2.3.9", mapped.PreviousVersion);
|
||||
Assert.Equal(1740, mapped.CurrentMedianSeconds);
|
||||
@@ -371,7 +448,7 @@ public class AppVersionRegressionRadarTests
|
||||
};
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
rows, sessionRepo.Object, App, AppInstallSources.Ime, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
Assert.Equal("1.0", version.GetProperty("appVersion").GetString());
|
||||
@@ -395,12 +472,12 @@ public class AppVersionRegressionRadarTests
|
||||
|
||||
// Empty-data early return still surfaces the block (episodes can outlive window data).
|
||||
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, days: 30,
|
||||
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
|
||||
@@ -131,7 +131,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
sessionRepo = mock.Object;
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
summaries, sessionRepo, "Contoso App", AppInstallSources.Ime, days: 30);
|
||||
return TestWire.SerializeToElement(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ public class AuditLogDeletionExclusionTests
|
||||
public void Global_fanout_filter_includes_deletion_exclusion_only_when_requested()
|
||||
{
|
||||
var without = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: false);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: false);
|
||||
var with = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
|
||||
|
||||
Assert.DoesNotContain("deletion_started", without);
|
||||
Assert.Contains("Action ne 'deletion_started'", with);
|
||||
@@ -47,7 +47,7 @@ public class AuditLogDeletionExclusionTests
|
||||
// noise leak back into one of the two views.
|
||||
var tenant = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
|
||||
var global = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
|
||||
|
||||
var clause = TableStorageService.DeletionExclusionClause();
|
||||
Assert.Contains(clause, tenant);
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AuditLogFieldFilterTests
|
||||
var filters = new AuditLogQueryFilters { Action = "device_blocked", EntityType = "Device" };
|
||||
|
||||
var f = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant, null, null, lastRowKey: null, excludeDeletions: false, filters);
|
||||
Tenant, null, null, rowKeyBound: null, excludeDeletions: false, filters);
|
||||
|
||||
Assert.Contains("Action eq 'device_blocked'", f);
|
||||
Assert.Contains("EntityType eq 'Device'", f);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -32,6 +36,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -108,12 +114,12 @@ public class AuthFunctionSideEffectTests
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
adminConfigService.Object);
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
@@ -202,7 +208,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -222,7 +228,11 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
// The DPA version rides along in the same onboarding write (D-252).
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("contoso.com", written.DomainName);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
@@ -237,6 +247,122 @@ public class AuthFunctionSideEffectTests
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returning tenant (D-288): the offboarding deleted the configuration, the pointer
|
||||
// carries the consumed self-service trial into the first-login write.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void SeedOffboardingPointer(bool trialConsumed) =>
|
||||
_offboardingRepo.Pointers[TenantId] = (new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
}, "\"etag\"");
|
||||
|
||||
private static TenantConfiguration ReturningTenantDefault()
|
||||
{
|
||||
// What auth/me hands over once the offboarding deleted the row: an unpersisted default.
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
Assert.False(config.TrialConsumed);
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_ReturningTenantThatUsedTheTrial_CarriesItIntoTheOnboardingWrite()
|
||||
{
|
||||
SeedOffboardingPointer(trialConsumed: true);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(config.TrialConsumed);
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.True(written.TrialConsumed);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
// Re-onboarding itself stays unrestricted: same signup path, auto-approve included.
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
// The verify criterion of the finding: the trial gate refuses the returning tenant.
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, DateTime.UtcNow);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, deny?.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // offboarded without ever using the trial
|
||||
[InlineData(null)] // never offboarded
|
||||
public async Task HandleNewTenantDomain_NoConsumedTrialOnRecord_LeavesTheTrialAvailable(bool? pointerTrialConsumed)
|
||||
{
|
||||
if (pointerTrialConsumed is { } consumed)
|
||||
SeedOffboardingPointer(consumed);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.False(config.TrialConsumed);
|
||||
Assert.Equal("contoso.com", config.DomainName);
|
||||
Assert.False(Assert.Single(_store.Writes).Row.TrialConsumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_OffboardingLookupFails_WritesNothingAndRetriesOnTheNextLogin()
|
||||
{
|
||||
// An onboarded row without the carry-over would hand the trial back; no write at all instead.
|
||||
_offboardingRepo.ThrowOnPointerRead = new Azure.RequestFailedException(503, "Server busy");
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_ConcurrentFirstLogin_NeverOverwritesOnboardedBy()
|
||||
{
|
||||
// This login's view still has no domain, but another login seeded the stored row meanwhile:
|
||||
// OnboardedBy is write-once (D-061), and the signup side effects belong to the winner.
|
||||
var stored = DefaultConfig();
|
||||
stored.DomainName = "contoso.com";
|
||||
stored.OnboardedBy = "first@contoso.com";
|
||||
_store.Row = stored;
|
||||
var view = DefaultConfig();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(view, TenantId, "second@contoso.com");
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_OffboardingTombstone_IsNeverSeeded()
|
||||
{
|
||||
// The offboard click writes a Disabled tombstone even onto a row without a domain.
|
||||
var tombstone = DefaultConfig();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
@@ -261,16 +387,16 @@ public class AuthFunctionSideEffectTests
|
||||
fresh.LastAuthClientId = LegacyAppId;
|
||||
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
_store.Row = 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);
|
||||
// The FRESH row was mutated and written — the flip survives, the stale view is discarded.
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.Equal(PrimaryAppId, written.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, written.LastAuthClientId);
|
||||
Assert.NotNull(written.LastAuthClientIdSince);
|
||||
Assert.Null(cached.HomedAppClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,11 +412,11 @@ public class AuthFunctionSideEffectTests
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
_store.Row = fresh;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -303,8 +429,9 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
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);
|
||||
_tenantConfigMock.Verify(x => x.UpdateAsync(It.IsAny<string>(), It.IsAny<Func<TenantConfiguration, bool>>(),
|
||||
It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
|
||||
@@ -321,7 +448,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Null(config.DpaVersion);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -332,10 +459,12 @@ public class AuthFunctionSideEffectTests
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
config.OnboardedBy = "original.requester@contoso.com";
|
||||
_store.Row = ConfigRepoMockExtensions.Clone(config);
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Equal("original.requester@contoso.com", config.OnboardedBy);
|
||||
Assert.Equal("original.requester@contoso.com", Assert.Single(_store.Writes).Row.OnboardedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -346,7 +475,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -358,7 +487,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -369,7 +498,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -385,8 +514,8 @@ public class AuthFunctionSideEffectTests
|
||||
// Should not throw — Telegram is fire-and-forget
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// SaveConfig should still have been called before the fire-and-forget
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
// The write still landed before the fire-and-forget
|
||||
Assert.Single(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -400,14 +529,68 @@ public class AuthFunctionSideEffectTests
|
||||
config.Disabled = true;
|
||||
config.DisabledReason = "Maintenance";
|
||||
config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired
|
||||
_store.Row = ConfigRepoMockExtensions.Clone(config);
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.False(config.Disabled);
|
||||
Assert.Null(config.DisabledReason);
|
||||
Assert.Null(config.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.False(written.Disabled);
|
||||
Assert.Null(written.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", written.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_SuspensionRenewedMeanwhile_StaysSuspended()
|
||||
{
|
||||
// This instance cached the expired suspension; a Global Admin renewed it since.
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledReason = "Maintenance";
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
var stored = ConfigRepoMockExtensions.Clone(view);
|
||||
stored.DisabledReason = "Abuse review";
|
||||
stored.DisabledUntil = DateTime.UtcNow.AddDays(2);
|
||||
_store.Row = stored;
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(view.IsCurrentlyDisabled());
|
||||
Assert.Equal("Abuse review", view.DisabledReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_RowIsTheOffboardingTombstone_StaysSuspended()
|
||||
{
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
var tombstone = ConfigRepoMockExtensions.Clone(view);
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
tombstone.DisabledUntil = null;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(view.IsCurrentlyDisabled());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_StorageFails_ThisLoginStillPasses()
|
||||
{
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
_store.ThrowOnRead = new Azure.RequestFailedException(503, "Server busy");
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.False(view.Disabled);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -420,7 +603,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -431,7 +614,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -444,7 +627,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -63,7 +63,7 @@ public class AuthenticationMiddlewareTests
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")] // AuthenticatedUserWithRole
|
||||
[InlineData("GET", "/api/sessions")] // MemberRead
|
||||
[InlineData("GET", "/api/audit/logs")] // MemberRead
|
||||
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
|
||||
[InlineData("PATCH", "/api/config/11111111-1111-1111-1111-111111111111/fields")] // TenantAdminOrGA
|
||||
[InlineData("GET", "/api/global/sessions")] // GlobalReadOrDelegatedSubset
|
||||
[InlineData("POST", "/api/global/raw/logs")] // GlobalAdminOnly (KQL proxy)
|
||||
[InlineData("GET", "/api/health/detailed")] // AuthenticatedUser (sub-route is NOT blanket-exempt)
|
||||
|
||||
@@ -152,7 +152,7 @@ public class BusinessTimestampFilterClauseTests
|
||||
|
||||
var plain = TableStorageService.BuildAuditLogFilter(Tenant, from, to);
|
||||
var fanOut = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant, from, to, lastRowKey: "!0123_x", excludeDeletions: false);
|
||||
Tenant, from, to, rowKeyBound: new PerPartitionFanOutMerge.RowKeyBound("!0123_x", Inclusive: false), excludeDeletions: false);
|
||||
|
||||
foreach (var f in new[] { plain!, fanOut })
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -13,6 +15,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// must validate successfully. This guards against rollouts where the embedded root/intermediate
|
||||
/// bundle drifts from the chain Microsoft is actually issuing.
|
||||
/// - Negative path: self-signed leaf certs (with or without Intune-shaped DNs) must NOT validate.
|
||||
/// - No network I/O: a leaf from an unknown CA must be rejected without fetching its AIA URL.
|
||||
/// </summary>
|
||||
public class CertificateValidatorTests
|
||||
{
|
||||
@@ -131,6 +134,31 @@ public class CertificateValidatorTests
|
||||
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_WithUnknownIssuerAndAiaUrl_NeverFetchesTheIssuer()
|
||||
{
|
||||
// The caller mints the leaf, so the AIA caIssuers URL points wherever they like. A
|
||||
// self-signed leaf never reaches the issuer-download path; it takes a leaf signed by a
|
||||
// CA that is neither embedded nor sent.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var b64 = CreateLeafFromUnknownCaBase64($"http://127.0.0.1:{port}/{Guid.NewGuid():N}.cer");
|
||||
|
||||
var result = CertificateValidator.ValidateCertificate(b64);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(listener.Pending(), "Chain building connected to the certificate's AIA URL");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSamplePath()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertificateValidatorTests).Assembly.Location)!;
|
||||
@@ -165,4 +193,24 @@ public class CertificateValidatorTests
|
||||
|
||||
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||
}
|
||||
|
||||
private static string CreateLeafFromUnknownCaBase64(string aiaCaIssuersUrl)
|
||||
{
|
||||
using var caKey = RSA.Create(2048);
|
||||
var caReq = new CertificateRequest("CN=Unknown Test CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
using var ca = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafReq = new CertificateRequest("CN=test-device.contoso.example", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
|
||||
critical: false));
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509AuthorityInformationAccessExtension(ocspUris: null, caIssuersUris: new[] { aiaCaIssuersUrl }));
|
||||
using var leaf = leafReq.Create(ca, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddDays(30), RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
return Convert.ToBase64String(leaf.Export(X509ContentType.Cert));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -40,14 +40,23 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
[Theory]
|
||||
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData(null, "portal-put")]
|
||||
[InlineData("", "portal-put")]
|
||||
[InlineData("something-else", "portal-put")]
|
||||
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
|
||||
[InlineData("?intent=collect-logs", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
|
||||
[InlineData("?intent=COLLECT-LOGS", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
|
||||
[InlineData("", "patch", "api-patch")]
|
||||
[InlineData("?intent=", "patch", "api-patch")]
|
||||
[InlineData("?intent=something-else", "patch", "api-patch")]
|
||||
[InlineData("?intent=collect-logs", "revert", "api-revert")] // a revert is never the quick-config dialog
|
||||
public void ResolveSource_only_honours_the_allow_listed_intent(string query, string operation, string expected)
|
||||
{
|
||||
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
|
||||
var (req, _) = EndpointHarness.Request("t1", queryString: query);
|
||||
Assert.Equal(expected, PatchTenantConfigurationFieldsFunction.ResolveSource(req, operation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSource_mcp_header_without_intent_is_mcp()
|
||||
{
|
||||
var (req, _) = EndpointHarness.Request("t1", headers: new Dictionary<string, string> { ["X-Client-Source"] = "mcp" });
|
||||
Assert.Equal("mcp-patch", PatchTenantConfigurationFieldsFunction.ResolveSource(req, "patch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,7 +65,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
|
||||
@@ -85,7 +94,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ public class ConfigBackupHookTests
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, 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);
|
||||
@@ -102,13 +115,13 @@ public class ConfigBackupHookTests
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", "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("api-patch", 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.
|
||||
@@ -123,11 +136,11 @@ public class ConfigBackupHookTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_NoExistingRow_NoSnapshot()
|
||||
public async Task RowCreation_ConditionalInsert_NoSnapshot()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
|
||||
var saved = await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig());
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
@@ -137,6 +150,16 @@ public class ConfigBackupHookTests
|
||||
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_RowAlreadyExists_ReturnsFalse()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
harness.Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null));
|
||||
|
||||
Assert.False(await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
|
||||
{
|
||||
@@ -150,7 +173,7 @@ public class ConfigBackupHookTests
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
incoming.UpdatedBy = "System (auth)";
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "auth", null);
|
||||
|
||||
Assert.True(saved); // the save itself still goes through
|
||||
Assert.Single(harness.Upserts);
|
||||
@@ -164,7 +187,7 @@ public class ConfigBackupHookTests
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(RoundtripClone(stored), "\"etag\"", "api-patch", null);
|
||||
|
||||
Assert.True(saved);
|
||||
harness.Backup.Verify(
|
||||
@@ -183,14 +206,14 @@ public class ConfigBackupHookTests
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config write went through regardless
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
|
||||
public async Task NoSource_StillSnapshots_WithUnknownSource()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
@@ -203,7 +226,7 @@ public class ConfigBackupHookTests
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
await harness.Sut.SaveTenantConfigurationAsync(incoming);
|
||||
await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", null, null);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("unknown", snapshot!.Source);
|
||||
@@ -225,15 +248,11 @@ public class ConfigBackupHookTests
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
var result = await harness.Sut.UpdateAdminConfigurationAsync(
|
||||
c => { c.GlobalRateLimitRequestsPerMinute = 200; return null; }, "ga@operator.example");
|
||||
|
||||
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(new[] { "GlobalRateLimitRequestsPerMinute" }, result.ChangedColumns);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
|
||||
Assert.Equal("admin-config", snapshot.Source);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
internal static class ConfigRepoMockExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that no tenant configuration row was created or replaced — the only two ways the
|
||||
/// repository can write one (D-290).
|
||||
/// </summary>
|
||||
public static void VerifyNoTenantConfigWrite(this Mock<IConfigRepository> repo)
|
||||
{
|
||||
repo.Verify(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()), Times.Never);
|
||||
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backs <see cref="TenantConfigurationService.UpdateAsync"/> and
|
||||
/// <see cref="TenantConfigurationService.CreateOrUpdateAsync"/> of a service mock with one stored row
|
||||
/// and the real contract: the mutation sees a fresh copy (never the caller's object), the offboarding
|
||||
/// tombstone is refused, a declined mutation writes nothing, and every write is recorded.
|
||||
/// </summary>
|
||||
public static StoredTenantConfig StubWrites(this Mock<TenantConfigurationService> service, string tenantId, TenantConfiguration? row)
|
||||
{
|
||||
var store = new StoredTenantConfig { Row = row };
|
||||
|
||||
Task<TenantConfigUpdate> Run(Func<TenantConfiguration, bool> mutate, string source, string reason, bool createIfMissing)
|
||||
{
|
||||
if (store.Force is { } forced)
|
||||
return Task.FromResult(new TenantConfigUpdate(forced, null));
|
||||
if (store.ThrowOnRead is { } ex)
|
||||
throw ex;
|
||||
|
||||
TenantConfiguration fresh;
|
||||
if (store.Row == null)
|
||||
{
|
||||
if (!createIfMissing)
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null));
|
||||
fresh = TenantConfiguration.CreateDefault(tenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
fresh = Clone(store.Row);
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(fresh))
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, fresh));
|
||||
}
|
||||
|
||||
if (!mutate(fresh))
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, fresh));
|
||||
|
||||
fresh.LastUpdated = DateTime.UtcNow;
|
||||
store.Row = fresh;
|
||||
store.Writes.Add((fresh, source, reason));
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, fresh));
|
||||
}
|
||||
|
||||
service.Setup(x => x.UpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, false));
|
||||
service.Setup(x => x.CreateOrUpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, true));
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backs the tenant-configuration reads and writes of a repository mock with one stored row and
|
||||
/// the storage contract: every read hands back a fresh copy, the replace succeeds only with the
|
||||
/// current ETag, the insert only while no row exists, and every write is recorded.
|
||||
/// </summary>
|
||||
public static StoredTenantConfig BackWithRow(this Mock<IConfigRepository> repo, TenantConfiguration? row)
|
||||
{
|
||||
var store = new StoredTenantConfig { Row = row };
|
||||
var version = 0;
|
||||
string ETag() => $"etag-{version}";
|
||||
|
||||
TenantConfiguration? Read()
|
||||
{
|
||||
if (store.ThrowOnRead is { } ex) throw ex;
|
||||
return store.Row == null ? null : Clone(store.Row);
|
||||
}
|
||||
|
||||
bool Write(TenantConfiguration config, string source, string reason)
|
||||
{
|
||||
if (store.ThrowOnWrite is { } ex) throw ex;
|
||||
if (store.RejectWrites) return false;
|
||||
store.Row = Clone(config);
|
||||
version++;
|
||||
store.Writes.Add((store.Row, source, reason));
|
||||
return true;
|
||||
}
|
||||
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => Read());
|
||||
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(() => Read() is { } fresh ? (fresh, ETag()) : null);
|
||||
repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration c, string etag, string? source, string? reason) =>
|
||||
store.Row != null && etag == ETag() && Write(c, source ?? "", reason ?? ""));
|
||||
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.ReturnsAsync((TenantConfiguration c) => store.Row == null && Write(c, "(create)", ""));
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>A storage round trip: what a fresh read of the stored row hands back.</summary>
|
||||
public static TenantConfiguration Clone(TenantConfiguration config)
|
||||
=> TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
}
|
||||
|
||||
/// <summary>The stored row behind <see cref="ConfigRepoMockExtensions.StubWrites"/> and what was written to it.</summary>
|
||||
internal sealed class StoredTenantConfig
|
||||
{
|
||||
public TenantConfiguration? Row { get; set; }
|
||||
public List<(TenantConfiguration Row, string Source, string Reason)> Writes { get; } = new();
|
||||
|
||||
/// <summary>When set, every write returns this status without touching the row (e.g. Conflict).</summary>
|
||||
public TenantConfigUpdateStatus? Force { get; set; }
|
||||
|
||||
/// <summary>When set, every read (and, for the service stub, every write) throws it — a storage outage.</summary>
|
||||
public Exception? ThrowOnRead { get; set; }
|
||||
|
||||
/// <summary>Repository double only: when set, every write throws it.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <summary>Repository double only: every conditional write loses (as if a concurrent writer always won).</summary>
|
||||
public bool RejectWrites { get; set; }
|
||||
}
|
||||
@@ -713,31 +713,6 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("POST", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/config/{0}/feature-flags")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("POST", "/api/tenants/{0}/admins")]
|
||||
@@ -96,8 +95,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("POST", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/config/{0}/feature-flags")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("POST", "/api/tenants/{0}/admins")]
|
||||
@@ -118,7 +116,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("DELETE", "/api/tenants/{0}/offboard")]
|
||||
public void CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string pathTemplate)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal HTTP endpoint harness: a mocked <see cref="HttpRequestData"/> whose FunctionContext carries the
|
||||
/// <see cref="RequestContext"/> the policy middleware would have resolved, serialized with the production
|
||||
/// wire settings.
|
||||
/// </summary>
|
||||
internal static class EndpointHarness
|
||||
{
|
||||
public static (HttpRequestData Req, FunctionContext Context) Request(
|
||||
string tenantId, string upn = "admin@contoso.com", string? jsonBody = null, string queryString = "",
|
||||
IDictionary<string, string>? headers = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>
|
||||
{
|
||||
["RequestContext"] = new RequestContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TargetTenantId = tenantId,
|
||||
UserPrincipalName = upn,
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
},
|
||||
};
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
var headerCollection = new HttpHeadersCollection();
|
||||
foreach (var (name, value) in headers ?? new Dictionary<string, string>())
|
||||
headerCollection.Add(name, value);
|
||||
req.SetupGet(r => r.Headers).Returns(headerCollection);
|
||||
// Query parses the Url in the base class (a bare mock would hand back null).
|
||||
req.SetupGet(r => r.Url).Returns(new Uri("https://localhost/api/test" + queryString));
|
||||
req.SetupGet(r => r.Query).CallBase();
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(jsonBody ?? "")));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeResponse(context.Object));
|
||||
return (req.Object, context.Object);
|
||||
}
|
||||
|
||||
/// <summary>The <c>code</c> of an error envelope; null when the body carries none.</summary>
|
||||
public static string? ErrorCode(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
private sealed class FakeResponse : HttpResponseData
|
||||
{
|
||||
public FakeResponse(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -156,14 +156,17 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
public void SameRoute_DifferentMethods_ResolveToDifferentPolicies()
|
||||
{
|
||||
var getConfig = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/tenant-1");
|
||||
var putConfig = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1");
|
||||
var patchConfig = EndpointAccessPolicyCatalog.FindPolicy("PATCH", "/api/config/tenant-1/fields");
|
||||
|
||||
Assert.NotNull(getConfig);
|
||||
Assert.NotNull(putConfig);
|
||||
Assert.NotNull(patchConfig);
|
||||
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
|
||||
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
|
||||
// view; the handler redacts for every non-admin caller); the field PATCH stays Admin/GA only.
|
||||
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, patchConfig.Policy);
|
||||
// There is no full-model write of the tenant configuration (D-290).
|
||||
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1"));
|
||||
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/config/tenant-1"));
|
||||
|
||||
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
|
||||
var postRules = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/gather");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -140,7 +141,7 @@ public class GenericWebhookTests
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
// ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
|
||||
// ── TenantConfigValidation.ValidateWebhookCustomHeaders ────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
@@ -149,7 +150,7 @@ public class GenericWebhookTests
|
||||
[InlineData("{\"Authorization\":\"Bearer abc\"}")]
|
||||
public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
|
||||
{
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookCustomHeaders(json));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -160,7 +161,7 @@ public class GenericWebhookTests
|
||||
[InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
|
||||
public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
|
||||
var error = TenantConfigValidation.ValidateWebhookCustomHeaders(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedFragment, error);
|
||||
}
|
||||
|
||||
@@ -115,4 +115,21 @@ public class GeographicDrilldownFilterTests
|
||||
Assert.Equal("Saxony", fal.Region);
|
||||
Assert.Equal("Falkenstein", fal.City);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("city")]
|
||||
[InlineData("region")]
|
||||
[InlineData("country")]
|
||||
public void CountryOnlySession_KeyIsTheCountryAndDrillsDownToItself(string groupBy)
|
||||
{
|
||||
// The agent's last-resort geo provider returns the country only.
|
||||
var countryOnly = Geo("de-only", "DE", "", "");
|
||||
var sessions = new List<SessionSummary> { countryOnly, Geo("de-bav", "DE", "Bavaria", "Munich") };
|
||||
|
||||
var key = GetGeographicMetricsFunction.GetLocationKey(countryOnly, groupBy);
|
||||
var drilled = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, key, groupBy);
|
||||
|
||||
Assert.Equal("DE", key); // never ", DE" or ", , DE"
|
||||
Assert.Contains(drilled, s => s.SessionId == "de-only");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,7 @@ public class McpUserServiceTenantMcpSwitchTests
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
_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);
|
||||
_configRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -218,14 +218,14 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("https://second.example/hook", channels[1].Url);
|
||||
}
|
||||
|
||||
// ── Save-time validation (UpdateTenantConfigurationFunction) ─────────
|
||||
// ── Save-time validation (TenantConfigValidation) ─────────
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_NullEmptyOrValidList_PassesThrough()
|
||||
{
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(null));
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(""));
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(null));
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(""));
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-2", providerType: 10) + "]"));
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
if (expectedFragment.Length > 0)
|
||||
Assert.Contains(expectedFragment, error);
|
||||
@@ -245,7 +245,7 @@ public class NotificationChannelsTests
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsDuplicateIds()
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(
|
||||
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-1") + "]");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("duplicate", error);
|
||||
@@ -255,7 +255,7 @@ public class NotificationChannelsTests
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"not-json\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("headers", error);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public class NotificationChannelsTests
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("signing secret", error);
|
||||
}
|
||||
|
||||
+6
@@ -88,9 +88,14 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>When set, every pointer read throws — a storage outage on the re-onboarding lookup.</summary>
|
||||
public Exception? ThrowOnPointerRead { get; set; }
|
||||
|
||||
public Task<(OffboardingByTenantPointer? Pointer, string? ETag)> TryGetByTenantPointerAsync(
|
||||
string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnPointerRead is { } ex)
|
||||
throw ex;
|
||||
if (Pointers.TryGetValue(normalizedTenantId, out var entry))
|
||||
{
|
||||
return Task.FromResult<(OffboardingByTenantPointer?, string?)>((Clone(entry.Pointer), entry.ETag));
|
||||
@@ -152,5 +157,6 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
PartitionKey = p.PartitionKey, RowKey = p.RowKey, TenantId = p.TenantId,
|
||||
LatestHistoryRowKey = p.LatestHistoryRowKey, LatestStatus = p.LatestStatus,
|
||||
LatestUpdatedAt = p.LatestUpdatedAt, OffboardCount = p.OffboardCount,
|
||||
TrialConsumed = p.TrialConsumed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ public class OpsEventFieldFilterTests
|
||||
// unfiltered rows — the exact bug this test exists to prevent.
|
||||
var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" };
|
||||
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters);
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound(
|
||||
"Agent", null, null, new PerPartitionFanOutMerge.RowKeyBound("0009", Inclusive: false), filters);
|
||||
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut);
|
||||
Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut);
|
||||
|
||||
@@ -1,325 +1,283 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using static AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-partition merge used by both the global all-category ops-events page
|
||||
/// and the global cross-tenant audit-logs page. Without this fan-out, Azure
|
||||
/// pages cross-partition queries by (PK asc, RK asc) — so page 1 would be
|
||||
/// entirely the alphabetically-first partition, masking newer items from
|
||||
/// later-alphabet partitions. The merge picks top-pageSize by timestamp across
|
||||
/// all per-partition fetches.
|
||||
/// Cross-partition paging behind the global all-category ops-events page and the global
|
||||
/// cross-tenant audit-log page. Every page fans out one read per partition and merges by
|
||||
/// (RowKey, Partition); the continuation is the watermark of the last returned row. The tests
|
||||
/// drive the helper against an in-memory partition store that honours the RowKey bound exactly
|
||||
/// like the OData clause does.
|
||||
/// </summary>
|
||||
public class PerPartitionFanOutMergeTests
|
||||
{
|
||||
private static OpsEventEntry Entry(string partition, DateTime ts, string id)
|
||||
=> new() { Category = partition, Id = id, Timestamp = ts, EventType = "synthetic" };
|
||||
|
||||
private static (string RowKey, OpsEventEntry Item) Row(DateTime ts, string id)
|
||||
// Reverse-tick RowKey like the storage layer writes it: a smaller string is a newer row.
|
||||
private static string Rk(int minutesAgo, string suffix = "")
|
||||
{
|
||||
// Mimics the storage-layer revtick rowkey: smaller string = newer timestamp.
|
||||
var rk = $"{(DateTime.MaxValue.Ticks - ts.Ticks):D19}_{id}";
|
||||
return (rk, Entry(partition: "<set-by-caller>", ts, id));
|
||||
var t0 = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
return $"{DateTime.MaxValue.Ticks - t0.AddMinutes(-minutesAgo).Ticks:D19}{suffix}";
|
||||
}
|
||||
|
||||
private static PartitionFetchResult<OpsEventEntry> Fetched(
|
||||
string partition, params (string RowKey, OpsEventEntry Item)[] rows)
|
||||
private sealed class Store
|
||||
{
|
||||
var aligned = rows
|
||||
.Select(r => (r.RowKey, Item: new OpsEventEntry
|
||||
private readonly Dictionary<string, List<string>> _rows;
|
||||
private readonly Func<string, bool> _matches;
|
||||
public List<(string Partition, RowKeyBound? Bound, int Limit)> Calls { get; } = new();
|
||||
|
||||
public Store(Dictionary<string, List<string>> rows, Func<string, bool>? matches = null)
|
||||
{
|
||||
_rows = rows;
|
||||
_matches = matches ?? (_ => true);
|
||||
}
|
||||
|
||||
public IEnumerable<string> Partitions => _rows.Keys;
|
||||
|
||||
public PartitionReader<string> Reader => (partition, bound, limit, _) =>
|
||||
{
|
||||
lock (Calls) Calls.Add((partition, bound, limit));
|
||||
var read = _rows[partition]
|
||||
.Where(_matches)
|
||||
.Where(rk => bound == null
|
||||
|| (bound.Inclusive
|
||||
? string.CompareOrdinal(rk, bound.RowKey) >= 0
|
||||
: string.CompareOrdinal(rk, bound.RowKey) > 0))
|
||||
.OrderBy(rk => rk, StringComparer.Ordinal)
|
||||
.Take(limit)
|
||||
.Select(rk => (rk, $"{partition}/{rk}"))
|
||||
.ToList();
|
||||
return Task.FromResult(read);
|
||||
};
|
||||
|
||||
/// <summary>The full newest-first order the pages must reproduce.</summary>
|
||||
public List<string> Expected() => _rows
|
||||
.SelectMany(kv => kv.Value.Where(_matches).Select(rk => (Partition: kv.Key, RowKey: rk)))
|
||||
.OrderBy(r => r.RowKey, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Partition, StringComparer.Ordinal)
|
||||
.Select(r => $"{r.Partition}/{r.RowKey}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<List<string>>> WalkAsync(Store store, int pageSize)
|
||||
{
|
||||
var pages = new List<List<string>>();
|
||||
string? continuation = null;
|
||||
do
|
||||
{
|
||||
var page = await FetchPageAsync(store.Partitions, pageSize, continuation, store.Reader);
|
||||
pages.Add(page.Items.ToList());
|
||||
continuation = page.NextRawToken;
|
||||
if (pages.Count > 1000) Assert.Fail("Walk did not terminate");
|
||||
}
|
||||
while (continuation != null);
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ────────── Order ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task First_page_is_newest_across_partitions_not_alphabetically_first()
|
||||
{
|
||||
var store = new Store(new()
|
||||
{
|
||||
["Agent"] = new() { Rk(5) },
|
||||
["Security"] = new() { Rk(0) },
|
||||
["Tenant"] = new() { Rk(10) },
|
||||
});
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(new[] { $"Security/{Rk(0)}", $"Agent/{Rk(5)}", $"Tenant/{Rk(10)}" }, page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_page_reads_every_partition_unbounded_with_one_row_of_lookahead()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1) }, ["B"] = new(), ["C"] = new() { Rk(2) } });
|
||||
|
||||
await FetchPageAsync(store.Partitions, 15, null, store.Reader);
|
||||
|
||||
Assert.Equal(new[] { "A", "B", "C" }, store.Calls.Select(c => c.Partition).OrderBy(p => p));
|
||||
Assert.All(store.Calls, c => Assert.Null(c.Bound));
|
||||
Assert.All(store.Calls, c => Assert.Equal(16, c.Limit));
|
||||
}
|
||||
|
||||
// ────────── Watermark and bounds ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bound_is_exclusive_up_to_the_watermark_partition_and_inclusive_after_it()
|
||||
{
|
||||
var w = new Watermark("0000000000000000042", "Maintenance");
|
||||
|
||||
Assert.Null(BoundAfter(null, "Agent"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: false), BoundAfter(w, "Agent"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: false), BoundAfter(w, "Maintenance"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: true), BoundAfter(w, "Security"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bound_renders_as_an_escaped_odata_rowkey_clause()
|
||||
{
|
||||
Assert.Equal("RowKey gt '!0042_a'", new RowKeyBound("!0042_a", false).ToODataClause());
|
||||
Assert.Equal("RowKey ge 'it''s'", new RowKeyBound("it's", true).ToODataClause());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Equal_rowkeys_in_different_partitions_all_survive_a_page_boundary()
|
||||
{
|
||||
// Ops-event RowKeys are bare reverse ticks, so two categories can share one. With a
|
||||
// RowKey-only watermark the second row would be skipped after the cut.
|
||||
var shared = Rk(3);
|
||||
var store = new Store(new()
|
||||
{
|
||||
["Agent"] = new() { shared },
|
||||
["Security"] = new() { shared },
|
||||
["Tenant"] = new() { shared, Rk(4) },
|
||||
});
|
||||
|
||||
var pages = await WalkAsync(store, pageSize: 1);
|
||||
|
||||
Assert.Equal(store.Expected(), pages.SelectMany(p => p));
|
||||
Assert.Equal(4, pages.Count);
|
||||
}
|
||||
|
||||
// ────────── Exact end of list ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task No_continuation_when_the_rows_exactly_fill_the_page()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1), Rk(2), Rk(3) }, ["B"] = new() });
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(3, page.Items.Count);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continuation_when_one_row_is_left_over()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1), Rk(2), Rk(3), Rk(4) } });
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(3, page.Items.Count);
|
||||
Assert.NotNull(page.NextRawToken);
|
||||
}
|
||||
|
||||
// ────────── Full walks ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
var store = new Store(new()
|
||||
{
|
||||
["00000000-0000-0000-0000-000000000000"] = new() { Rk(0, "_g0"), Rk(40, "_g1") },
|
||||
["1a"] = new() { Rk(1, "_a"), Rk(2, "_a"), Rk(3, "_a"), Rk(4, "_a"), Rk(5, "_a"), Rk(6, "_a"), Rk(30, "_a") },
|
||||
["2b"] = new(),
|
||||
["3c"] = new() { Rk(2, "_c"), Rk(7, "_c"), Rk(8, "_c") },
|
||||
["4d"] = new() { Rk(9, "_a") },
|
||||
["5e"] = new() { Rk(9, "_a"), Rk(50, "_e") },
|
||||
});
|
||||
|
||||
var pages = await WalkAsync(store, pageSize);
|
||||
|
||||
var expected = store.Expected();
|
||||
Assert.Equal(expected, pages.SelectMany(p => p));
|
||||
Assert.All(pages, p => Assert.NotEmpty(p));
|
||||
Assert.All(pages.Take(pages.Count - 1), p => Assert.Equal(pageSize, p.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walk_with_a_server_side_filter_returns_exactly_the_matching_rows()
|
||||
{
|
||||
// The reader applies the filter (like `Action ne 'deletion_started'`), so excluded rows
|
||||
// never take a page slot and never become a watermark.
|
||||
var store = new Store(
|
||||
new()
|
||||
{
|
||||
Id = r.Item.Id,
|
||||
Category = partition,
|
||||
EventType = r.Item.EventType,
|
||||
Timestamp = r.Item.Timestamp,
|
||||
}))
|
||||
.ToArray();
|
||||
return new PartitionFetchResult<OpsEventEntry>(partition, aligned);
|
||||
}
|
||||
["A"] = new() { Rk(1, "_keep"), Rk(2, "_drop"), Rk(3, "_drop"), Rk(4, "_keep") },
|
||||
["B"] = new() { Rk(2, "_drop"), Rk(5, "_keep"), Rk(6, "_drop") },
|
||||
},
|
||||
matches: rk => rk.EndsWith("_keep", StringComparison.Ordinal));
|
||||
|
||||
// ────────── Merge picks newest-across-partitions, not within-partition ──
|
||||
var pages = await WalkAsync(store, pageSize: 2);
|
||||
|
||||
[Fact]
|
||||
public void First_page_returns_newest_across_partitions_not_alphabetically_first()
|
||||
{
|
||||
// Without the merge, Azure's (PK asc, RK asc) ordering would surface
|
||||
// "Agent" partition items first (alphabetically), even though "Security"
|
||||
// has a newer event. The merge must pick by timestamp, not partition.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent", Row(t0.AddMinutes(-5), "agent-1")),
|
||||
Fetched("Security", Row(t0, "sec-1")),
|
||||
Fetched("Tenant", Row(t0.AddMinutes(-10), "tenant-1")),
|
||||
};
|
||||
|
||||
var (items, _) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
|
||||
Assert.Equal(3, items.Count);
|
||||
Assert.Equal("sec-1", items[0].Id); // newest first
|
||||
Assert.Equal("agent-1", items[1].Id);
|
||||
Assert.Equal("tenant-1", items[2].Id);
|
||||
Assert.Equal(store.Expected(), pages.SelectMany(p => p));
|
||||
Assert.Equal(3, store.Expected().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_fetch_drops_partition_from_continuation_map()
|
||||
public async Task Empty_store_yields_one_empty_page_without_continuation()
|
||||
{
|
||||
// Wire-format v2 invariant: exhausted partitions are absent from the
|
||||
// continuation, not encoded with `x:true`. Consumers treat "missing"
|
||||
// as exhausted on subsequent pages — that's what shrinks the audit
|
||||
// fan-out token from 30+ KB to a few hundred bytes.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent"), // empty fetch
|
||||
Fetched("Security", Row(t0, "sec-1")),
|
||||
};
|
||||
var store = new Store(new() { ["A"] = new(), ["B"] = new() });
|
||||
|
||||
var (_, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 5,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
var page = await FetchPageAsync(store.Partitions, 15, null, store.Reader);
|
||||
|
||||
Assert.False(conts.ContainsKey("Agent"));
|
||||
Assert.True(conts.ContainsKey("Security"));
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
// ────────── Continuation advance: avoid losing leftovers when a partition loses the cut ──
|
||||
// ────────── Token ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Continuation_keeps_position_for_partitions_whose_items_lost_the_merge_cut()
|
||||
public async Task Continuation_size_does_not_grow_with_the_number_of_partitions()
|
||||
{
|
||||
// Page 1: pageSize=3. Agent fetched 5 items, Security fetched 5 items, but
|
||||
// Security's are all newer. Merge takes 3 from Security; Agent's 5 items
|
||||
// all lost the cut. Agent's continuation MUST stay at its previous
|
||||
// position so the items get re-fetched and compete on page 2.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent",
|
||||
Row(t0.AddMinutes(-100), "a1"),
|
||||
Row(t0.AddMinutes(-101), "a2"),
|
||||
Row(t0.AddMinutes(-102), "a3"),
|
||||
Row(t0.AddMinutes(-103), "a4"),
|
||||
Row(t0.AddMinutes(-104), "a5")),
|
||||
Fetched("Security",
|
||||
Row(t0, "s1"),
|
||||
Row(t0.AddMinutes(-1), "s2"),
|
||||
Row(t0.AddMinutes(-2), "s3"),
|
||||
Row(t0.AddMinutes(-3), "s4"),
|
||||
Row(t0.AddMinutes(-4), "s5")),
|
||||
};
|
||||
// The per-partition map this replaced grew with every partition holding rows until the
|
||||
// nextLink failed with HTTP 414. Every partition here has rows that lose the merge cut.
|
||||
static Store Tenants(int count) => new(Enumerable.Range(0, count).ToDictionary(
|
||||
i => Guid.Parse($"00000000-0000-0000-0000-{i:D12}").ToString(),
|
||||
i => new List<string> { Rk(i, "_" + Guid.Empty.ToString("N")), Rk(i + 1000, "_" + Guid.Empty.ToString("N")) }));
|
||||
|
||||
var (items, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
var few = await FetchPageAsync(Tenants(3).Partitions, 1, null, Tenants(3).Reader);
|
||||
var many = await FetchPageAsync(Tenants(500).Partitions, 1, null, Tenants(500).Reader);
|
||||
|
||||
// Security wins all three slots.
|
||||
Assert.Equal(new[] { "s1", "s2", "s3" }, items.Select(i => i.Id).ToArray());
|
||||
|
||||
// Agent had no items returned and a non-empty fetch → keep prior continuation
|
||||
// (null = restart). The partition stays in the map (active with no rk),
|
||||
// so the next page will re-fetch its items and they'll be re-considered
|
||||
// at a lower floor.
|
||||
Assert.True(conts.ContainsKey("Agent"));
|
||||
Assert.Null(conts["Agent"].LastRowKey);
|
||||
|
||||
Assert.True(conts.ContainsKey("Security"));
|
||||
Assert.NotNull(conts["Security"].LastRowKey);
|
||||
Assert.NotNull(many.NextRawToken);
|
||||
Assert.Equal(few.NextRawToken!.Length, many.NextRawToken!.Length);
|
||||
Assert.True(many.NextRawToken.Length < 200, $"raw continuation is {many.NextRawToken.Length} characters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Continuation_advances_to_oldest_returned_rowkey_per_partition()
|
||||
public void Watermark_round_trips()
|
||||
{
|
||||
// When merge takes some-but-not-all from a partition, advance only past the
|
||||
// items we actually returned. The leftover (older) items in that fetch
|
||||
// will be re-fetched and re-considered on the next page.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var (rkNew, _) = Row(t0, "x-new");
|
||||
var (rkMid, _) = Row(t0.AddMinutes(-5), "x-mid");
|
||||
var (rkOld, _) = Row(t0.AddMinutes(-10), "x-old");
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent",
|
||||
(rkNew, Entry("Agent", t0, "x-new")),
|
||||
(rkMid, Entry("Agent", t0.AddMinutes(-5), "x-mid")),
|
||||
(rkOld, Entry("Agent", t0.AddMinutes(-10), "x-old"))),
|
||||
// A second partition whose only event is between Agent's mid and old
|
||||
// items, ensuring the merge ends after Agent's mid item.
|
||||
Fetched("Security",
|
||||
Row(t0.AddMinutes(-7), "s-only")),
|
||||
};
|
||||
var w = new Watermark("!2516116980007133310_a4bb51498cad468f89431b15f1e701f7", "tenant-a");
|
||||
|
||||
var (items, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
|
||||
// Top 3 by timestamp desc: x-new, x-mid, s-only. x-old is dropped.
|
||||
Assert.Equal(new[] { "x-new", "x-mid", "s-only" }, items.Select(i => i.Id).ToArray());
|
||||
|
||||
// Agent's continuation advances to rkMid (the oldest Agent rowkey we returned).
|
||||
// x-old will be re-fetched and re-considered next page (filter: RowKey gt rkMid).
|
||||
Assert.Equal(rkMid, conts["Agent"].LastRowKey);
|
||||
}
|
||||
|
||||
// ────────── Codec round-trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Encode_then_decode_round_trips_active_partitions()
|
||||
{
|
||||
var original = new Dictionary<string, PartitionContinuation>
|
||||
{
|
||||
["Agent"] = new PartitionContinuation("0123456789012345678_aaa"),
|
||||
["Consent"] = new PartitionContinuation("0987654321098765432_bbb"),
|
||||
["Tenant"] = new PartitionContinuation(null), // active, restart from top
|
||||
};
|
||||
|
||||
var encoded = EncodeMultiContinuation(original);
|
||||
var decoded = DecodeMultiContinuation(encoded);
|
||||
|
||||
Assert.Equal(3, decoded.Count);
|
||||
Assert.Equal("0123456789012345678_aaa", decoded["Agent"].LastRowKey);
|
||||
Assert.Equal("0987654321098765432_bbb", decoded["Consent"].LastRowKey);
|
||||
Assert.Null(decoded["Tenant"].LastRowKey);
|
||||
Assert.Equal(w, DecodeWatermark(EncodeWatermark(w)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_silently_drops_legacy_v1_exhausted_entries()
|
||||
public void Decode_rejects_anything_that_is_not_a_v3_watermark()
|
||||
{
|
||||
// Wire format v1 (pre-fix) explicitly encoded `x:true` for exhausted
|
||||
// partitions; v2 just omits them. Tokens that were already in flight
|
||||
// when the deploy went out must continue to page through cleanly —
|
||||
// those legacy `x:true` entries are dropped silently so the
|
||||
// consumer's "missing = exhausted" rule applies.
|
||||
var legacyJson = """{"v":1,"c":{"Agent":{"rk":"0123456789012345678_aaa","x":false},"Security":{"rk":null,"x":true},"Consent":{"rk":"0987654321098765432_bbb","x":false}}}""";
|
||||
var legacyToken = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(legacyJson));
|
||||
static string B64(string json) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
|
||||
var decoded = DecodeMultiContinuation(legacyToken);
|
||||
|
||||
Assert.Equal(2, decoded.Count);
|
||||
Assert.True(decoded.ContainsKey("Agent"));
|
||||
Assert.True(decoded.ContainsKey("Consent"));
|
||||
Assert.False(decoded.ContainsKey("Security")); // dropped: was x:true in v1 wire
|
||||
Assert.Null(DecodeWatermark(null));
|
||||
Assert.Null(DecodeWatermark(""));
|
||||
Assert.Null(DecodeWatermark("not-base64-!@#"));
|
||||
Assert.Null(DecodeWatermark(B64("this is not json")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":2,"c":{"Agent":{"rk":"0123"}}}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":3,"rk":"","p":"Agent"}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":3,"rk":"0123"}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":"3","rk":"0123","p":"Agent"}""")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_omits_partition_state_field_to_keep_token_compact()
|
||||
public async Task A_token_from_an_older_deploy_ends_the_list_without_reading_storage()
|
||||
{
|
||||
// Sanity-check the wire compactness gain: 200 partitions of v1 with
|
||||
// `x:false` per entry produced ~30 KB after base64; v2 should be
|
||||
// dramatically smaller because we drop the redundant flag and only
|
||||
// encode active entries.
|
||||
var many = Enumerable.Range(0, 200)
|
||||
.ToDictionary(i => $"tenant-{i:D3}", i => new PartitionContinuation($"!1234567890123456789_{i:D8}"));
|
||||
var store = new Store(new() { ["Agent"] = new() { Rk(1) } });
|
||||
var v2 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("""{"v":2,"c":{"Agent":{"rk":null}}}"""));
|
||||
|
||||
var encoded = EncodeMultiContinuation(many);
|
||||
var page = await FetchPageAsync(store.Partitions, 15, v2, store.Reader);
|
||||
|
||||
// 200 entries × (~30 chars name + ~30 chars rk + JSON overhead) → JSON
|
||||
// ~12 KB → base64 ~16 KB. The legacy `x:false` flag adds another
|
||||
// ~10 bytes per entry (~2 KB total) which we now save. The hard
|
||||
// assertion below is on absolute size: we must stay well below the
|
||||
// ~30 KB that broke audit Next clicks.
|
||||
Assert.True(encoded.Length < 20_000,
|
||||
$"Encoded continuation should stay under 20 KB even at 200 partitions; got {encoded.Length} bytes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_empty_for_malformed_or_empty_continuation()
|
||||
{
|
||||
Assert.Empty(DecodeMultiContinuation(null));
|
||||
Assert.Empty(DecodeMultiContinuation(""));
|
||||
Assert.Empty(DecodeMultiContinuation("not-base64-!@#"));
|
||||
Assert.Empty(DecodeMultiContinuation("dGhpcyBpcyBub3QganNvbg==")); // valid base64, not JSON
|
||||
}
|
||||
|
||||
// ────────── End-to-end: drain across pages preserves all items, in order ──
|
||||
|
||||
[Fact]
|
||||
public void Multi_page_drain_returns_all_items_globally_newest_first_no_duplicates()
|
||||
{
|
||||
var t0 = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var allItems = new List<(string Part, OpsEventEntry Item, string Rk)>();
|
||||
var partitions = new[] { "Agent", "Security", "Tenant" };
|
||||
var minute = 0;
|
||||
foreach (var part in partitions)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
minute++;
|
||||
var ts = t0.AddMinutes(-minute);
|
||||
var (rk, _) = Row(ts, $"{part}-{i}");
|
||||
allItems.Add((part, new OpsEventEntry
|
||||
{
|
||||
Category = part,
|
||||
Id = $"{part}-{i}",
|
||||
Timestamp = ts,
|
||||
EventType = "synthetic",
|
||||
}, rk));
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<PartitionFetchResult<OpsEventEntry>> SimulateFetch(
|
||||
Dictionary<string, PartitionContinuation> continuations, int pageSize, bool isFirstPage)
|
||||
{
|
||||
var fetched = new List<PartitionFetchResult<OpsEventEntry>>();
|
||||
foreach (var part in partitions)
|
||||
{
|
||||
// Mirrors the consumer rule: on first page query everything; on
|
||||
// subsequent pages only partitions still in the continuation.
|
||||
if (!isFirstPage && !continuations.ContainsKey(part)) continue;
|
||||
continuations.TryGetValue(part, out var prior);
|
||||
var lastRk = prior?.LastRowKey;
|
||||
|
||||
var rows = allItems
|
||||
.Where(t => t.Part == part)
|
||||
.Where(t => lastRk == null || string.Compare(t.Rk, lastRk, StringComparison.Ordinal) > 0)
|
||||
.OrderBy(t => t.Rk, StringComparer.Ordinal)
|
||||
.Take(pageSize)
|
||||
.Select(t => (t.Rk, t.Item))
|
||||
.ToList();
|
||||
fetched.Add(new PartitionFetchResult<OpsEventEntry>(part, rows));
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
const int pageSize = 5;
|
||||
var seen = new List<OpsEventEntry>();
|
||||
var conts = new Dictionary<string, PartitionContinuation>();
|
||||
var loopGuard = 0;
|
||||
bool isFirstPage = true;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var fetched = SimulateFetch(conts, pageSize, isFirstPage);
|
||||
var (page, nextConts) = MergeAndAdvance(fetched, conts, pageSize, e => e.Timestamp);
|
||||
seen.AddRange(page);
|
||||
conts = nextConts;
|
||||
isFirstPage = false;
|
||||
|
||||
// Map only carries active partitions now — empty == drain complete.
|
||||
if (conts.Count == 0) break;
|
||||
if (page.Count == 0) break;
|
||||
if (++loopGuard > 50) Assert.Fail("Drain loop did not terminate");
|
||||
}
|
||||
|
||||
Assert.Equal(12, seen.Count);
|
||||
Assert.Equal(12, seen.Select(e => e.Id).Distinct().Count());
|
||||
for (int i = 1; i < seen.Count; i++)
|
||||
{
|
||||
Assert.True(seen[i - 1].Timestamp >= seen[i].Timestamp,
|
||||
$"Out-of-order at index {i}: {seen[i - 1].Id}({seen[i - 1].Timestamp}) before {seen[i].Id}({seen[i].Timestamp})");
|
||||
}
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
Assert.Empty(store.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
|
||||
// PUT config/{tenantId} is TenantAdminOrGA (write). Route tenant == JWT tenant ⇒ own-tenant write.
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed); // additive: the tenant-admin hat still grants write in the own tenant
|
||||
}
|
||||
@@ -361,7 +361,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
// No tenant membership ⇒ no write path.
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
@@ -700,7 +700,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); // even a DelegatedAdmin row…
|
||||
|
||||
// PUT config/{B} is TenantAdminOrGA (write). Phase 1: delegation never satisfies a write tier.
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
@@ -986,9 +986,7 @@ 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);
|
||||
h.ConfigRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
|
||||
@@ -1547,7 +1545,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
@@ -1576,7 +1574,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
AsSuspended(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
|
||||
@@ -43,8 +43,10 @@ public sealed class ProConferralServiceTests
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? cfg : null);
|
||||
configRepo.Setup(c => c.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration cfg, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
|
||||
configRepo.Setup(c => c.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? (cfg, "etag") : null);
|
||||
configRepo.Setup(c => c.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration cfg, string _, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
|
||||
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(a => a.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(members.ToList());
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RealmJoin packages as <c>AppInstallSummaries</c> rows (<see cref="AppInstallSources.RealmJoin"/>):
|
||||
/// the ingest fold of <c>realmjoin_package_started/completed</c>, the scope + package-id row
|
||||
/// identity, the store-side channel columns, and the channel dimension of the per-app aggregates
|
||||
/// (an Intune app and a RealmJoin package with the same display name never merge).
|
||||
/// </summary>
|
||||
public class RealmJoinPackageInstallRowsTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 9, 17, 8, 29, 26, DateTimeKind.Utc);
|
||||
private const string SessionId = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
// Same comparer as the production classification (EventIngestProcessor.Classification).
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", SessionId, summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static AppInstallSummary Row(Dictionary<string, AppInstallAggregationState> summaries, string packageId, string scope = "machine")
|
||||
=> summaries[EventIngestProcessor.RealmJoinAggregationKey(scope, packageId)].Summary;
|
||||
|
||||
private static EnrollmentEvent Started(DateTime at, string packageId = "generic-github-desktop",
|
||||
string displayName = "GitHub Desktop", string scope = "machine", string? version = "3.6.5.0")
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["packageId"] = packageId,
|
||||
["displayName"] = displayName,
|
||||
["scope"] = scope,
|
||||
};
|
||||
if (version != null) data["version"] = version;
|
||||
return new EnrollmentEvent { EventType = Constants.EventTypes.RealmJoinPackageStarted, Timestamp = at, Data = data };
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Completed(DateTime at, bool success = true, int exitCode = 0,
|
||||
string packageId = "generic-github-desktop", string displayName = "GitHub Desktop", string scope = "machine")
|
||||
{
|
||||
var evt = Started(at, packageId, displayName, scope);
|
||||
evt.EventType = Constants.EventTypes.RealmJoinPackageCompleted;
|
||||
// Wire form of the agent adapter: strings.
|
||||
evt.Data!["success"] = success ? "true" : "false";
|
||||
evt.Data["lastExitCode"] = exitCode.ToString();
|
||||
return evt;
|
||||
}
|
||||
|
||||
// ── ingest fold: live watcher events ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StartedThenCompleted_OneRealmJoinRow_WithMeasuredDuration()
|
||||
{
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(22))), "generic-github-desktop");
|
||||
|
||||
Assert.Equal(AppInstallSources.RealmJoin, s.Source);
|
||||
Assert.Equal("generic-github-desktop", s.AppId);
|
||||
Assert.Equal("machine", s.InstallScope);
|
||||
Assert.Equal("GitHub Desktop", s.AppName);
|
||||
Assert.Equal("3.6.5.0", s.AppVersion);
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal("Installed", s.TerminalState);
|
||||
Assert.Equal(0, s.ExitCode);
|
||||
Assert.Equal(string.Empty, s.FailureCode);
|
||||
Assert.Equal(T0, s.StartedAt);
|
||||
Assert.Equal(T0, s.LastAttemptStartedAt);
|
||||
Assert.Equal(T0.AddSeconds(22), s.CompletedAt);
|
||||
Assert.Equal(22, s.DurationSeconds);
|
||||
Assert.Equal(1, s.InstallPassCount);
|
||||
Assert.True(MetricsMath.HasMeasuredDuration(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedPackage_ExitCodeIsTheFailureCode()
|
||||
{
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(40), success: false, exitCode: 1603)), "generic-github-desktop");
|
||||
|
||||
Assert.Equal("Failed", s.Status);
|
||||
Assert.Equal("Error", s.TerminalState);
|
||||
Assert.Equal(1603, s.ExitCode);
|
||||
Assert.Equal("1603", s.FailureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAndCompletedInOneRegistryPass_IsUnmeasured()
|
||||
{
|
||||
// Older agents report both events in the same watcher pass, milliseconds apart: that is
|
||||
// no install duration.
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddMilliseconds(4))), "generic-github-desktop");
|
||||
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal(0, s.DurationSeconds);
|
||||
Assert.False(MetricsMath.HasMeasuredDuration(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDisplayName_UsesPackageId_UntilANameArrives()
|
||||
{
|
||||
var summaries = Aggregate(Started(T0, displayName: string.Empty));
|
||||
Assert.Equal("generic-github-desktop", Row(summaries, "generic-github-desktop").AppName);
|
||||
|
||||
EventIngestProcessor.AggregateAppInstallEvent(Completed(T0.AddSeconds(5)), "tenant", SessionId, summaries);
|
||||
Assert.Equal("GitHub Desktop", Row(summaries, "generic-github-desktop").AppName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePackageIdInBothScopes_IsTwoRows()
|
||||
{
|
||||
var summaries = Aggregate(
|
||||
Completed(T0, scope: "machine"),
|
||||
Completed(T0, success: false, exitCode: 1, scope: "user"));
|
||||
|
||||
Assert.Equal("Succeeded", Row(summaries, "generic-github-desktop", "machine").Status);
|
||||
Assert.Equal("Failed", Row(summaries, "generic-github-desktop", "user").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoinPackage_AndIntuneApp_WithTheSameName_AreTwoRows()
|
||||
{
|
||||
var ime = new EnrollmentEvent
|
||||
{
|
||||
EventType = "app_install_completed",
|
||||
Timestamp = T0,
|
||||
Data = new Dictionary<string, object> { ["appName"] = "GitHub Desktop", ["state"] = "Installed" },
|
||||
};
|
||||
var summaries = Aggregate(ime, Completed(T0.AddSeconds(1)));
|
||||
|
||||
Assert.Equal(2, summaries.Count);
|
||||
Assert.Equal(string.Empty, summaries["GitHub Desktop"].Summary.Source);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, Row(summaries, "generic-github-desktop").Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventWithoutPackageId_IsIgnored()
|
||||
{
|
||||
var evt = Completed(T0);
|
||||
evt.Data!.Remove("packageId");
|
||||
Assert.Empty(Aggregate(evt));
|
||||
}
|
||||
|
||||
// ── store: row identity, channel columns, reconcile ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RowKey_ImeUnchanged_RealmJoinKeyedByScopeAndPackageId()
|
||||
{
|
||||
Assert.Equal($"{SessionId}_Contoso App_2",
|
||||
TableStorageService.BuildAppInstallSummaryRowKey(new AppInstallSummary { SessionId = SessionId, AppName = "Contoso App/2" }));
|
||||
|
||||
var rj = new AppInstallSummary
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
InstallScope = "user",
|
||||
AppId = "generic-7zip-usersettings",
|
||||
AppName = "7-Zip Settings",
|
||||
};
|
||||
var key = TableStorageService.BuildAppInstallSummaryRowKey(rj);
|
||||
Assert.Equal($"{SessionId}_realmjoin|user|generic-7zip-usersettings", key);
|
||||
// The per-session range scans (ESP stamp, close step, deletion) match on this prefix.
|
||||
Assert.StartsWith($"{SessionId}_", key, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entity_ImeRowHasNoChannelColumns_RealmJoinRowRoundTrips()
|
||||
{
|
||||
var ime = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { TenantId = "t", SessionId = SessionId, AppName = "App" }, "rk");
|
||||
Assert.False(ime.ContainsKey("Source"));
|
||||
Assert.False(ime.ContainsKey("InstallScope"));
|
||||
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
TenantId = "t",
|
||||
SessionId = SessionId,
|
||||
AppName = "7-Zip",
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
InstallScope = "machine",
|
||||
AppId = "generic-7zip",
|
||||
};
|
||||
var entity = TableStorageService.BuildAppInstallSummaryEntity(summary, "rk");
|
||||
var sut = new TableStorageService(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
|
||||
var mapped = sut.MapToAppInstallSummary(entity);
|
||||
|
||||
Assert.Equal(AppInstallSources.RealmJoin, mapped.Source);
|
||||
Assert.Equal("machine", mapped.InstallScope);
|
||||
Assert.Equal("generic-7zip", mapped.AppId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_PackageIdFallbackName_KeepsTheStoredDisplayName()
|
||||
{
|
||||
var existing = new TableEntity("t", "rk") { ["AppName"] = "7-Zip", ["StartedAt"] = T0 };
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
AppId = "generic-7zip",
|
||||
AppName = "generic-7zip",
|
||||
StartedAt = T0.AddMinutes(30),
|
||||
};
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
Assert.Equal("7-Zip", summary.AppName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspBlockingStamp_NeverTouchesRealmJoinRows()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(new Dictionary<string, object>
|
||||
{
|
||||
["espTrackedWin32AppIds"] = new[] { "generic-7zip" },
|
||||
})!;
|
||||
var rj = new TableEntity("t", "rk") { ["Source"] = AppInstallSources.RealmJoin, ["AppId"] = "generic-7zip" };
|
||||
var ime = new TableEntity("t", "rk2") { ["AppId"] = "generic-7zip" };
|
||||
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(rj, sets));
|
||||
Assert.True(TableStorageService.ShouldStampEspBlocking(ime, sets));
|
||||
}
|
||||
|
||||
// ── per-app aggregates: the channel is part of the app ──────────────────
|
||||
|
||||
private static AppInstallSummary Summary(string appName, string source, string status = "Succeeded", int duration = 60)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "t",
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
Status = status,
|
||||
TerminalState = status == "Failed" ? "Error" : "Installed",
|
||||
DurationSeconds = duration,
|
||||
StartedAt = DateTime.UtcNow.AddDays(-1),
|
||||
LastAttemptStartedAt = DateTime.UtcNow.AddDays(-1),
|
||||
};
|
||||
|
||||
private static List<AppInstallSummary> MixedChannelRows() => new()
|
||||
{
|
||||
Summary("GitHub Desktop", string.Empty),
|
||||
Summary("GitHub Desktop", AppInstallSources.RealmJoin, status: "Failed"),
|
||||
Summary("GitHub Desktop", AppInstallSources.RealmJoin),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AppsList_SameNameInTwoChannels_IsTwoItems()
|
||||
{
|
||||
var root = TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(MixedChannelRows(), days: 30));
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
|
||||
Assert.Equal(2, apps.Count);
|
||||
var ime = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.Ime);
|
||||
var rj = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.RealmJoin);
|
||||
Assert.Equal(1, ime.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(2, rj.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, rj.GetProperty("failed").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_And_Sessions_FilterByChannel()
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var rj = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, rj.Source);
|
||||
Assert.Equal(2, rj.Summary.TotalInstalls);
|
||||
Assert.Equal(1, rj.Summary.Failed);
|
||||
|
||||
var ime = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.Ime, days: 30);
|
||||
Assert.Equal(AppInstallSources.Ime, ime.Source);
|
||||
Assert.Equal(1, ime.Summary.TotalInstalls);
|
||||
|
||||
var sessions = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30,
|
||||
statusFilter: "all", modelFilter: null, versionFilter: null, offset: 0, limit: 50);
|
||||
Assert.Equal(2, sessions.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppMetrics_GroupsPerChannel()
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(MixedChannelRows());
|
||||
|
||||
Assert.Equal(2, payload.TotalApps);
|
||||
var failing = Assert.Single(payload.TopFailingApps);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, failing.Source);
|
||||
Assert.Equal("GitHub Desktop", failing.AppName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true, "ime")]
|
||||
[InlineData("", true, "ime")]
|
||||
[InlineData("realmjoin", true, "realmjoin")]
|
||||
[InlineData(" RealmJoin ", true, "realmjoin")]
|
||||
[InlineData("office-c2r", false, "office-c2r")]
|
||||
public void SourceQueryParam(string? raw, bool valid, string expected)
|
||||
{
|
||||
Assert.Equal(valid, AppsAnalyticsHelper.TryParseSourceQueryParam(raw, out var source));
|
||||
Assert.Equal(expected, source);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -94,14 +96,29 @@ public class ReportAgentErrorFunctionTests
|
||||
// (2026-07-23 hardening): timeline event + cross-session EventType index row +
|
||||
// AgentEmergencyBreak ops event, once per session. The index upsert is load-bearing:
|
||||
// StoreEventsBatchAsync alone leaves the event invisible to every search-by-eventType
|
||||
// surface (found in the 2026-07-22 incident analysis).
|
||||
// surface (found in the 2026-07-22 incident analysis). A session without a writable
|
||||
// row gets the ops event only.
|
||||
// =========================================================================
|
||||
|
||||
private static TableEntity Row(SessionStatus? status = SessionStatus.InProgress, string? deletionState = null)
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId);
|
||||
if (status.HasValue) row["Status"] = status.Value.ToString();
|
||||
if (deletionState != null) row["DeletionState"] = deletionState;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ReportAgentErrorFunction.SessionRowRead Read(TableEntity? row) => new(row, Failed: false);
|
||||
|
||||
private static readonly ReportAgentErrorFunction.SessionRowRead ReadFailed = new(null, Failed: true);
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly Mock<ISessionRepository> SessionRepo = new();
|
||||
public readonly List<OpsEventEntry> OpsEvents = new();
|
||||
public readonly OpsEventService OpsService;
|
||||
private readonly SessionDeletionGuard _deletionGuard = new(
|
||||
Mock.Of<ISessionDeletionInventoryReader>(), NullLogger<SessionDeletionGuard>.Instance);
|
||||
|
||||
public Harness(List<EnrollmentEvent>? existingEvents = null)
|
||||
{
|
||||
@@ -125,9 +142,23 @@ public class ReportAgentErrorFunctionTests
|
||||
OpsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
|
||||
/// <summary>Default read: an existing, open session row — the historical path.</summary>
|
||||
public Task RunAsync(AgentErrorReport report)
|
||||
=> RunAsync(report, Read(Row()));
|
||||
|
||||
public Task RunAsync(AgentErrorReport report, ReportAgentErrorFunction.SessionRowRead? sessionRead)
|
||||
=> ReportAgentErrorFunction.MaterializeEmergencyBreakArtifactsAsync(
|
||||
report, TenantId, SessionRepo.Object, OpsService, NullLogger<ReportAgentErrorFunction>.Instance);
|
||||
report, TenantId, sessionRead, SessionRepo.Object, _deletionGuard, OpsService,
|
||||
NullLogger<ReportAgentErrorFunction>.Instance);
|
||||
|
||||
public void VerifyNothingWrittenIntoTheSession()
|
||||
{
|
||||
SessionRepo.Verify(r => r.GetSessionEventsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
SessionRepo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Never);
|
||||
SessionRepo.Verify(r => r.UpsertEventTypeIndexBatchAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<EnrollmentEvent>>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,7 +196,7 @@ public class ReportAgentErrorFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_break_error_types_and_missing_session_id_are_ignored()
|
||||
public async Task Non_break_error_types_and_ids_without_a_row_read_are_ignored()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
@@ -173,12 +204,10 @@ public class ReportAgentErrorFunctionTests
|
||||
other.ErrorType = AgentErrorType.ConfigFetchFailed;
|
||||
await h.RunAsync(other);
|
||||
|
||||
var noSession = Report(Now);
|
||||
noSession.SessionId = null!;
|
||||
await h.RunAsync(noSession);
|
||||
// No read = the ids were not GUIDs (ReadSessionRowAsync) — no session can exist to break.
|
||||
await h.RunAsync(Report(Now), sessionRead: null);
|
||||
|
||||
h.SessionRepo.Verify(r => r.GetSessionEventsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
h.VerifyNothingWrittenIntoTheSession();
|
||||
Assert.Empty(h.OpsEvents);
|
||||
}
|
||||
|
||||
@@ -192,10 +221,8 @@ public class ReportAgentErrorFunctionTests
|
||||
public async Task Break_on_already_terminal_session_is_info_late_cleanup(SessionStatus status)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SessionRepo.Setup(r => r.GetSessionAsync(TenantId, SessionId))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId, Status = status });
|
||||
|
||||
await h.RunAsync(Report(Now));
|
||||
await h.RunAsync(Report(Now), Read(Row(status)));
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Info, ops.Severity);
|
||||
@@ -216,44 +243,131 @@ public class ReportAgentErrorFunctionTests
|
||||
public async Task Break_on_open_session_stays_warning(SessionStatus status)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SessionRepo.Setup(r => r.GetSessionAsync(TenantId, SessionId))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId, Status = status });
|
||||
|
||||
await h.RunAsync(Report(Now));
|
||||
await h.RunAsync(Report(Now), Read(Row(status)));
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Warning, ops.Severity);
|
||||
Assert.Contains($"still {status}", ops.Message);
|
||||
Assert.Contains("\"lateCleanup\":false", ops.Details);
|
||||
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Once);
|
||||
}
|
||||
|
||||
// 2026-09-28 sweep: every "unknown" Warning named a session that never registered. The break is
|
||||
// the only thing the backend ever hears of it — nothing to close, and a timeline event would be
|
||||
// an orphan the maintenance sweep deletes a day later (with a Warning of its own).
|
||||
[Fact]
|
||||
public async Task Break_on_session_without_row_is_info_and_writes_nothing_into_the_session()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.RunAsync(Report(Now), Read(null));
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Info, ops.Severity);
|
||||
Assert.Contains("no session row", ops.Message);
|
||||
Assert.Contains("\"sessionStatusAtBreak\":\"missing\"", ops.Details);
|
||||
Assert.Contains("\"lateCleanup\":false", ops.Details);
|
||||
h.VerifyNothingWrittenIntoTheSession();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AutopilotMonitor.Shared.Models.Deletion.SessionDeletionState.Preparing)]
|
||||
[InlineData(AutopilotMonitor.Shared.Models.Deletion.SessionDeletionState.Queued)]
|
||||
[InlineData(AutopilotMonitor.Shared.Models.Deletion.SessionDeletionState.Running)]
|
||||
[InlineData(AutopilotMonitor.Shared.Models.Deletion.SessionDeletionState.Poisoned)]
|
||||
public async Task Break_on_session_under_cascade_delete_writes_nothing_past_the_lock(string deletionState)
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.RunAsync(Report(Now), Read(Row(SessionStatus.InProgress, deletionState)));
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Info, ops.Severity);
|
||||
Assert.Contains("\"sessionStatusAtBreak\":\"deleting\"", ops.Details);
|
||||
h.VerifyNothingWrittenIntoTheSession();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_or_unreadable_session_status_keeps_warning()
|
||||
public async Task Unreadable_row_keeps_the_historical_warning_and_write()
|
||||
{
|
||||
// No row (Harness default: GetSessionAsync → null) — the historical Warning semantics.
|
||||
// Existence unknown — fail safe: the break may be the only signal for an open session.
|
||||
var h = new Harness();
|
||||
await h.RunAsync(Report(Now));
|
||||
|
||||
await h.RunAsync(Report(Now), ReadFailed);
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Warning, ops.Severity);
|
||||
Assert.Contains("\"sessionStatusAtBreak\":\"unknown\"", ops.Details);
|
||||
|
||||
// Row read throws — the break must still be recorded, still as Warning.
|
||||
var h2 = new Harness();
|
||||
h2.SessionRepo.Setup(r => r.GetSessionAsync(TenantId, SessionId))
|
||||
.ThrowsAsync(new InvalidOperationException("table down"));
|
||||
await h2.RunAsync(Report(Now));
|
||||
Assert.Equal(OpsEventSeverity.Warning, Assert.Single(h2.OpsEvents).Severity);
|
||||
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyEmergencyBreak_is_terminal_set_only()
|
||||
public async Task Row_without_parseable_status_stays_warning()
|
||||
{
|
||||
Assert.Equal((OpsEventSeverity.Info, true), ReportAgentErrorFunction.ClassifyEmergencyBreak(SessionStatus.Succeeded));
|
||||
Assert.Equal((OpsEventSeverity.Info, true), ReportAgentErrorFunction.ClassifyEmergencyBreak(SessionStatus.Failed));
|
||||
Assert.Equal((OpsEventSeverity.Info, true), ReportAgentErrorFunction.ClassifyEmergencyBreak(SessionStatus.Incomplete));
|
||||
Assert.Equal((OpsEventSeverity.Warning, false), ReportAgentErrorFunction.ClassifyEmergencyBreak(SessionStatus.AwaitingUser));
|
||||
Assert.Equal((OpsEventSeverity.Warning, false), ReportAgentErrorFunction.ClassifyEmergencyBreak(SessionStatus.Unknown));
|
||||
Assert.Equal((OpsEventSeverity.Warning, false), ReportAgentErrorFunction.ClassifyEmergencyBreak(null));
|
||||
var h = new Harness();
|
||||
|
||||
await h.RunAsync(Report(Now), Read(Row(status: null)));
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventSeverity.Warning, ops.Severity);
|
||||
Assert.Contains("\"sessionStatusAtBreak\":\"unknown\"", ops.Details);
|
||||
}
|
||||
|
||||
// The agent-measured age separates a break just past the cap from a device that slept for
|
||||
// weeks; the backend cannot derive it (no row, or a row registered seconds before the break).
|
||||
[Fact]
|
||||
public async Task Reported_session_age_lands_in_ops_details_and_timeline_data()
|
||||
{
|
||||
var h = new Harness();
|
||||
var report = Report(Now);
|
||||
report.SessionAgeHours = 478.2;
|
||||
|
||||
await h.RunAsync(report);
|
||||
|
||||
Assert.Contains("\"sessionAgeHours\":478.2", Assert.Single(h.OpsEvents).Details);
|
||||
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(
|
||||
It.Is<List<EnrollmentEvent>>(e => (double)e[0].Data["sessionAgeHours"] == 478.2)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_without_session_age_leaves_it_null_and_out_of_the_timeline()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.RunAsync(Report(Now));
|
||||
|
||||
Assert.Contains("\"sessionAgeHours\":null", Assert.Single(h.OpsEvents).Details);
|
||||
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(
|
||||
It.Is<List<EnrollmentEvent>>(e => !e[0].Data.ContainsKey("sessionAgeHours"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyEmergencyBreak_follows_row_state()
|
||||
{
|
||||
static ReportAgentErrorFunction.EmergencyBreakVerdict Classify(TableEntity? row, bool failed = false, bool locked = false)
|
||||
=> ReportAgentErrorFunction.ClassifyEmergencyBreak(row, failed, locked);
|
||||
|
||||
foreach (var terminal in new[] { SessionStatus.Succeeded, SessionStatus.Failed, SessionStatus.Incomplete })
|
||||
{
|
||||
var v = Classify(Row(terminal));
|
||||
Assert.Equal((OpsEventSeverity.Info, true, true, terminal.ToString()), (v.Severity, v.LateCleanup, v.Materialize, v.SessionStatusAtBreak));
|
||||
}
|
||||
|
||||
foreach (var open in new[] { SessionStatus.InProgress, SessionStatus.Pending, SessionStatus.AwaitingUser, SessionStatus.Stalled, SessionStatus.Unknown })
|
||||
{
|
||||
var v = Classify(Row(open));
|
||||
Assert.Equal((OpsEventSeverity.Warning, false, true, open.ToString()), (v.Severity, v.LateCleanup, v.Materialize, v.SessionStatusAtBreak));
|
||||
}
|
||||
|
||||
var missing = Classify(null);
|
||||
Assert.Equal((OpsEventSeverity.Info, false, false, "missing"), (missing.Severity, missing.LateCleanup, missing.Materialize, missing.SessionStatusAtBreak));
|
||||
|
||||
var deleting = Classify(Row(SessionStatus.Succeeded), locked: true);
|
||||
Assert.Equal((OpsEventSeverity.Info, false, false, "deleting"), (deleting.Severity, deleting.LateCleanup, deleting.Materialize, deleting.SessionStatusAtBreak));
|
||||
|
||||
var unreadable = Classify(null, failed: true);
|
||||
Assert.Equal((OpsEventSeverity.Warning, false, true, "unknown"), (unreadable.Severity, unreadable.LateCleanup, unreadable.Materialize, unreadable.SessionStatusAtBreak));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING call-site inventory. The functions are too dependency-heavy to boot in a
|
||||
/// unit test, and the refusal is a one-line branch at each site — so the guard is structural:
|
||||
/// every file that observes the binding must also act on <c>Decision.Rejected</c>, and the set of
|
||||
/// sites is pinned so a new session-scoped agent endpoint is added here on purpose (and decides
|
||||
/// how it refuses) instead of silently observing without enforcing.
|
||||
/// </summary>
|
||||
public class SessionOwnerBindingCallSiteGuardTests
|
||||
{
|
||||
private const string ObserveCall = "_ownerBinding.Observe(";
|
||||
|
||||
private static readonly string[] ExpectedSites =
|
||||
{
|
||||
"Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs",
|
||||
"Functions/Ingest/IngestTelemetryFunction.cs",
|
||||
"Functions/Ingest/ReportAgentErrorFunction.cs",
|
||||
"Functions/Sessions/RegisterSessionFunction.cs",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_observing_site_acts_on_the_refusal()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var sites = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
if (text.Contains(ObserveCall, StringComparison.Ordinal))
|
||||
sites[relative] = text;
|
||||
}
|
||||
|
||||
Assert.Equal(ExpectedSites, sites.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
|
||||
|
||||
var missing = sites.Where(kv => !kv.Value.Contains(".Rejected", StringComparison.Ordinal)).Select(kv => kv.Key).ToList();
|
||||
Assert.True(missing.Count == 0,
|
||||
"These sites observe the session owner binding but never act on Decision.Rejected:\n " + string.Join("\n ", missing));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING-SHADOW carriers: the outcome lands on the request row for every
|
||||
/// request (denominator), non-Match outcomes produce a Warning, would-reject outcomes raise one
|
||||
/// throttled ops event, stamping is fail-soft, and nothing is ever refused.
|
||||
/// SESSION-OWNER-BINDING carriers: the outcome lands on the request row for every request
|
||||
/// (denominator), non-Match outcomes produce a Warning, rejected outcomes raise one throttled
|
||||
/// ops event, stamping is fail-soft, and an evaluation defect fails open.
|
||||
/// </summary>
|
||||
public class SessionOwnerBindingObserverTests
|
||||
{
|
||||
@@ -105,7 +105,7 @@ public class SessionOwnerBindingObserverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Would_reject_outcome_warns_and_raises_one_ops_event_per_session_and_outcome()
|
||||
public async Task Rejected_outcome_warns_and_raises_one_ops_event_per_session_and_outcome()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (req, items) = Request("2.0.1500");
|
||||
@@ -115,12 +115,12 @@ public class SessionOwnerBindingObserverTests
|
||||
var second = rig.Sut.Observe(req, Tenant, Session, row, Cert(Thumb2, "SN-ATTACKER"), "agent/telemetry");
|
||||
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, first.Outcome);
|
||||
Assert.True(first.WouldReject);
|
||||
Assert.True(first.Rejected);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, items[SessionOwnershipPolicy.RequestItemKey]);
|
||||
|
||||
var warnings = rig.Logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
Assert.Equal(2, warnings.Count);
|
||||
Assert.All(warnings, w => Assert.Contains("AgentSessionOwnerBinding outcome=MismatchCert enforced=False wouldReject=True", w.Message));
|
||||
Assert.All(warnings, w => Assert.Contains("AgentSessionOwnerBinding outcome=MismatchCert enforced=True wouldReject=True", w.Message));
|
||||
Assert.Contains("serialMatch=False", warnings[0].Message);
|
||||
Assert.Contains("ver=2.0.1500", warnings[0].Message);
|
||||
|
||||
@@ -130,11 +130,11 @@ public class SessionOwnerBindingObserverTests
|
||||
Assert.Equal(OpsEventCategory.Security, evt.Category);
|
||||
Assert.Equal(Tenant, evt.TenantId);
|
||||
Assert.Contains("MismatchCert", evt.Message);
|
||||
Assert.Contains("shadow", evt.Message);
|
||||
Assert.Contains("refused", evt.Message);
|
||||
Assert.DoesNotContain(Thumb1, evt.Message);
|
||||
Assert.DoesNotContain(Thumb2, evt.Message);
|
||||
Assert.Contains("\"serialMatch\":false", evt.Details);
|
||||
Assert.Contains("\"enforced\":false", evt.Details);
|
||||
Assert.Contains("\"enforced\":true", evt.Details);
|
||||
_ = second;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,22 @@ public class SessionOwnerBindingObserverTests
|
||||
Assert.Empty(rig.Ops);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluation_defect_fails_open()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (req, _) = Request();
|
||||
|
||||
// A null validation throws inside the policy — our defect, never evidence of a foreign device.
|
||||
var d = rig.Sut.Observe(req, Tenant, Session, OwnedRow(Thumb1), null!, "agent/telemetry");
|
||||
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.Match, d.Outcome);
|
||||
Assert.False(d.Rejected);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.Contains(rig.Logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("request allowed"));
|
||||
Assert.Empty(rig.Ops);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stamp_writes_the_owner_and_swallows_storage_failures()
|
||||
{
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING rule matrix. The policy is pure, so every lifecycle shape that must keep
|
||||
/// working (restart, WhiteGlove Part 2, bootstrap→cert handoff, certificate re-issue, rows from
|
||||
/// before the binding) and every shape that must be visible as a would-reject (foreign cert,
|
||||
/// foreign bootstrap code, bootstrap on a cert-owned row, re-enroll without wipe) is pinned here.
|
||||
/// before the binding) and every shape that is refused (foreign cert, foreign bootstrap code,
|
||||
/// bootstrap on a cert-owned row, re-enroll without wipe) is pinned here.
|
||||
/// </summary>
|
||||
public class SessionOwnershipPolicyTests
|
||||
{
|
||||
@@ -65,7 +65,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(Thumb1, d.OwnerToStamp.Thumbprint);
|
||||
Assert.Equal(Dev1, d.OwnerToStamp.DeviceId);
|
||||
Assert.Equal("SN-1", d.OwnerToStamp.Serial);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +75,7 @@ public class SessionOwnershipPolicyTests
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), v, Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.CallerUnidentified, d.Outcome);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
// ── legacy rows (pre-binding) ────────────────────────────────────────────
|
||||
@@ -87,7 +87,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.ClaimLegacy, d.Outcome);
|
||||
Assert.NotNull(d.OwnerToStamp);
|
||||
Assert.True(d.SerialMatch);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,7 +96,7 @@ public class SessionOwnershipPolicyTests
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(serial: "OTHER"), Cert(Thumb1, Dev1), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.LegacySerialMismatch, d.Outcome);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +123,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.RebindCertRotation, d.Outcome);
|
||||
Assert.Equal(Thumb2, d.OwnerToStamp!.Thumbprint);
|
||||
Assert.Equal(Dev1, d.OwnerToStamp.DeviceId);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -133,7 +133,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome);
|
||||
Assert.False(d.SerialMatch);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -143,7 +143,7 @@ public class SessionOwnershipPolicyTests
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Cert(Thumb2, Dev2, "SN-1"), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome);
|
||||
Assert.True(d.SerialMatch);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -160,7 +160,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff, d.Outcome);
|
||||
Assert.Equal(SessionOwner.Kinds.Cert, d.OwnerToStamp!.Kind);
|
||||
Assert.Null(d.OwnerToStamp.BootstrapCode);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -168,7 +168,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Cert(Thumb1, Dev1, "OTHER"), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrapOwned, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
// ── bootstrap caller ─────────────────────────────────────────────────────
|
||||
@@ -187,7 +187,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Bootstrap(code, serial), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrap, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -195,7 +195,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Bootstrap("ABC123"), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.DowngradeToBootstrap, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -244,7 +244,7 @@ public class SessionOwnershipPolicyTests
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff)]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.CallerUnidentified)]
|
||||
public void Lifecycle_outcomes_are_deliberately_tolerated(string outcome)
|
||||
=> Assert.False(SessionOwnershipPolicy.WouldRejectUnderEnforcement(outcome));
|
||||
=> Assert.False(SessionOwnershipPolicy.Rejects(outcome));
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.LegacySerialMismatch)]
|
||||
@@ -252,16 +252,22 @@ public class SessionOwnershipPolicyTests
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.MismatchCert)]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.MismatchBootstrap)]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.DowngradeToBootstrap)]
|
||||
public void Foreign_identity_outcomes_would_reject(string outcome)
|
||||
=> Assert.True(SessionOwnershipPolicy.WouldRejectUnderEnforcement(outcome));
|
||||
public void Foreign_identity_outcomes_are_rejected(string outcome)
|
||||
=> Assert.True(SessionOwnershipPolicy.Rejects(outcome));
|
||||
|
||||
[Fact]
|
||||
public void Stage1_has_no_Rejects_rule()
|
||||
public void Every_outcome_is_classified_and_the_telemetry_field_reports_the_rule()
|
||||
{
|
||||
// Enforcement is a deliberate, visible code change (stage 2) — not something that can
|
||||
// slip in through a refactor. Delete this test in the change that adds Rejects.
|
||||
var rejects = typeof(SessionOwnershipPolicy).GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(m => m.Name == "Rejects").ToArray();
|
||||
Assert.Empty(rejects);
|
||||
// A new Outcome constant must land in one of the two theories above on purpose, and the
|
||||
// shadow-era wouldReject field (KQL keeps querying it) must say exactly what Rejects does.
|
||||
var outcomes = typeof(SessionOwnershipPolicy.Outcome)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral)
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(11, outcomes.Length);
|
||||
Assert.Equal(5, outcomes.Count(SessionOwnershipPolicy.Rejects));
|
||||
Assert.All(outcomes, o => Assert.Equal(SessionOwnershipPolicy.Rejects(o), SessionOwnershipPolicy.WouldRejectUnderEnforcement(o)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ public sealed class SharedManifestParityTests
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs).
|
||||
["appInstallSources"] = AppInstallSources.All,
|
||||
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
|
||||
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
|
||||
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Format checks and the address classifier. The connection gate itself is covered on real sockets
|
||||
/// in <see cref="WebhookConnectGateTests"/>.
|
||||
/// </summary>
|
||||
public class SsrfGuardTests
|
||||
{
|
||||
// ── ValidateWebhookUrlFormat: valid URLs ──
|
||||
@@ -145,37 +151,124 @@ public class SsrfGuardTests
|
||||
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
// ── ValidateDestinationAsync: integration ──
|
||||
// ── IsBlockedAddress: IPv6 beyond the classic ranges ──
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_Localhost_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("::")] // unspecified
|
||||
[InlineData("::ffff:8.8.8.8")] // IPv4-mapped, even of a public address
|
||||
[InlineData("::8.8.8.8")] // IPv4-compatible (deprecated)
|
||||
[InlineData("64:ff9b::a00:1")] // NAT64 of 10.0.0.1
|
||||
[InlineData("2002:a00:1::1")] // 6to4 of 10.0.0.1
|
||||
[InlineData("2001::1")] // Teredo
|
||||
[InlineData("2001:db8::1")] // documentation
|
||||
[InlineData("fec0::1")] // deprecated site-local
|
||||
[InlineData("4000::1")] // outside global unicast
|
||||
[InlineData("e000::1")] // outside global unicast
|
||||
public void IsBlocked_Ipv6OutsidePublicUnicast_ReturnsTrue(string ip)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("https://localhost/webhook"));
|
||||
Assert.Contains("private or reserved", ex.Message);
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_HttpScheme_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("fe80::1%5")]
|
||||
[InlineData("2606:4700:4700::1111%5")]
|
||||
public void IsBlocked_Ipv6WithZoneId_ReturnsTrue(string ip)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("http://example.com/webhook"));
|
||||
Assert.Contains("HTTPS", ex.Message);
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_InvalidUrl_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("2606:4700:4700::1111")] // Cloudflare
|
||||
[InlineData("2a00:1450:4001::1")] // Google
|
||||
[InlineData("2001:200::1")] // just above 2001::/23
|
||||
[InlineData("2003::1")] // just above 6to4 2002::/16
|
||||
[InlineData("2001:db9::1")] // just above documentation 2001:db8::/32
|
||||
[InlineData("192.0.1.0")] // just above 192.0.0.0/24
|
||||
[InlineData("198.20.0.0")] // just above benchmarking 198.18.0.0/15
|
||||
[InlineData("223.255.255.255")] // just below multicast
|
||||
public void IsBlocked_PublicNeighbourOfRefusedBlock_ReturnsFalse(string ip)
|
||||
{
|
||||
await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("not-a-url"));
|
||||
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
// ── IsBlockedAddress: every IANA special-purpose block (tests/fixtures/iana-special-purpose-addresses) ──
|
||||
|
||||
private static readonly string[] RegistryFiles = { "iana-ipv4-special-registry-1.csv", "iana-ipv6-special-registry-1.csv" };
|
||||
|
||||
public static IEnumerable<object[]> RegistryBlocks() =>
|
||||
RegistryFiles.SelectMany(file => ReadRegistryBlocks(file).Select(block => new object[] { file, block }));
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_UnresolvableHost_ThrowsSsrfException()
|
||||
public void Registry_IsReadCompletely()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("https://this-host-does-not-exist-xyz123.invalid/webhook"));
|
||||
Assert.Contains("resolve", ex.Message);
|
||||
foreach (var file in RegistryFiles)
|
||||
{
|
||||
var blocks = ReadRegistryBlocks(file);
|
||||
Assert.True(blocks.Count >= 20, $"{file}: only {blocks.Count} blocks read");
|
||||
Assert.All(blocks, block => Assert.Matches(@"^[0-9a-f.:]+/\d{1,3}$", block));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryBlocks))]
|
||||
public void IsBlocked_EveryIanaSpecialPurposeBlock_FirstAndLastAddress(string file, string block)
|
||||
{
|
||||
var network = IPNetwork.Parse(block);
|
||||
var first = network.BaseAddress.GetAddressBytes();
|
||||
var last = (byte[])first.Clone();
|
||||
for (var bit = network.PrefixLength; bit < last.Length * 8; bit++)
|
||||
last[bit / 8] |= (byte)(0x80 >> (bit % 8));
|
||||
|
||||
foreach (var ip in new[] { new IPAddress(first), new IPAddress(last) })
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(ip), $"{file}: {block} -> {ip}");
|
||||
}
|
||||
|
||||
private static List<string> ReadRegistryBlocks(string file)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
var rows = ParseCsv(File.ReadAllText(Path.Combine(dir!.FullName, "tests", "fixtures", "iana-special-purpose-addresses", file)));
|
||||
Assert.Equal("Address Block", rows[0][0]);
|
||||
// Footnote markers ("2002::/16 [3]") dropped, cells that list two blocks split.
|
||||
return rows.Skip(1)
|
||||
.SelectMany(row => row[0].Split(','))
|
||||
.Select(block => Regex.Replace(block, @"\[\d+\]", "").Trim())
|
||||
.Where(block => block.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 reader — the registry quotes cells, wraps the RFC column over lines and doubles quotes.</summary>
|
||||
private static List<List<string>> ParseCsv(string text)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var cell = new StringBuilder();
|
||||
var quoted = false;
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (quoted)
|
||||
{
|
||||
if (c == '"' && i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i++; }
|
||||
else if (c == '"') quoted = false;
|
||||
else cell.Append(c);
|
||||
}
|
||||
else if (c == '"') quoted = true;
|
||||
else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); }
|
||||
else if (c == '\n' || c == '\r')
|
||||
{
|
||||
if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||
row.Add(cell.ToString());
|
||||
rows.Add(row);
|
||||
row = new List<string>();
|
||||
cell.Clear();
|
||||
}
|
||||
else cell.Append(c);
|
||||
}
|
||||
if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── ValidateAzureBlobSasUrlFormat ──
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
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;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantConfigurationService.UpdateAsync"/> and the plan writers built on it: the decision runs
|
||||
/// on the fresh row and the write is conditional on that row's ETag — a cached view (up to 5 minutes stale
|
||||
/// per instance) is never saved, so it can neither rewind another writer's change nor lift the offboarding
|
||||
/// tombstone during the drain barrier, and two concurrent trial starts cannot both see TrialConsumed=false.
|
||||
/// </summary>
|
||||
public class TenantConfigUpdateTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly Queue<(TenantConfiguration Row, string ETag)> _freshReads = new();
|
||||
private readonly List<(TenantConfiguration Config, string ETag, string? Source, string? Reason)> _replaces = new();
|
||||
private readonly Queue<bool> _replaceResults = new();
|
||||
private readonly List<TenantConfiguration> _creates = new();
|
||||
private readonly Queue<bool> _createResults = new();
|
||||
|
||||
public TenantConfigUpdateTests()
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => _freshReads.Count > 0 ? _freshReads.Dequeue() : null);
|
||||
_repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string, string?, string?>((c, e, s, r) => _replaces.Add((c, e, s, r)))
|
||||
.ReturnsAsync(() => _replaceResults.Count == 0 || _replaceResults.Dequeue());
|
||||
_repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => _creates.Add(c))
|
||||
.ReturnsAsync(() => _createResults.Count == 0 || _createResults.Dequeue());
|
||||
}
|
||||
|
||||
private TenantConfigurationService Service(ManagedTenantProIndex? proIndex = null) =>
|
||||
new(_repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()), proIndex ?? ManagedTenantProIndex.None);
|
||||
|
||||
private static TenantConfiguration Row(Action<TenantConfiguration>? shape = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.DomainName = "contoso.com";
|
||||
row.ContactEmail = "it@contoso.com";
|
||||
row.CompanyName = "Contoso";
|
||||
shape?.Invoke(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static TenantConfiguration Tombstone() => Row(r =>
|
||||
{
|
||||
r.Disabled = true;
|
||||
r.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
});
|
||||
|
||||
/// <summary>Primes the service's cache with <paramref name="cached"/>, the view a warm instance would hold.</summary>
|
||||
private async Task PrimeCacheAsync(TenantConfigurationService service, TenantConfiguration cached)
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(cached);
|
||||
Assert.Same(cached, await service.GetConfigurationIfExistsAsync(TenantId));
|
||||
}
|
||||
|
||||
// ── UpdateAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutatesTheFreshRow_UnderItsETag_NeverTheCachedView()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.HomedAppClientId = null));
|
||||
_freshReads.Enqueue((Row(r => r.HomedAppClientId = "flipped-meanwhile"), "etag-1"));
|
||||
|
||||
var update = await service.UpdateAsync(TenantId, row => { row.PayingCustomer = true; return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("flipped-meanwhile", written.HomedAppClientId);
|
||||
Assert.True(written.PayingCustomer);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("why", reason);
|
||||
|
||||
// The cached view is gone: the next read goes to storage.
|
||||
await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
_repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_LostRace_RereadsAndDecidesAgainOnTheNewRow()
|
||||
{
|
||||
var first = Row();
|
||||
var second = Row(r => r.CompanyName = "Contoso after the concurrent write");
|
||||
_freshReads.Enqueue((first, "etag-1"));
|
||||
_freshReads.Enqueue((second, "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
_replaceResults.Enqueue(true);
|
||||
var seen = new List<TenantConfiguration>();
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, row => { seen.Add(row); return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
Assert.Equal(new[] { first, second }, seen);
|
||||
Assert.Equal(new[] { "etag-1", "etag-2" }, _replaces.Select(r => r.ETag));
|
||||
Assert.Same(second, update.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_EveryAttemptLosesTheRace_Conflict()
|
||||
{
|
||||
for (var i = 0; i < TenantConfigurationService.MaxUpdateAttempts; i++)
|
||||
{
|
||||
_freshReads.Enqueue((Row(), $"etag-{i}"));
|
||||
_replaceResults.Enqueue(false);
|
||||
}
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Conflict, update.Status);
|
||||
Assert.Equal(TenantConfigurationService.MaxUpdateAttempts, _replaces.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OffboardingTombstone_IsNeverWritten()
|
||||
{
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var mutated = false;
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => mutated = true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.OffboardingInProgress, update.Status);
|
||||
Assert.False(mutated);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutationDeclines_NothingIsWritten()
|
||||
{
|
||||
var row = Row();
|
||||
_freshReads.Enqueue((row, "etag-1"));
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => false, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Declined, update.Status);
|
||||
Assert.Same(row, update.Config);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_NoRow_NotFound()
|
||||
{
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.NotFound, update.Status);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TheMutationSeesTheConferredProProjection()
|
||||
{
|
||||
// EvaluateTrialStart's AlreadyPro verdict for a managed tenant depends on it.
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
string? seen = null;
|
||||
|
||||
await Service(new StubManagedTenantProIndex(_ => "managing-tenant"))
|
||||
.UpdateAsync(TenantId, row => { seen = row.ManagedByProTenantId; return false; }, "plan", "why");
|
||||
|
||||
Assert.Equal("managing-tenant", seen);
|
||||
}
|
||||
|
||||
// ── CreateOrUpdateAsync + the default row ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NoRow_InsertsTheMutatedDefault()
|
||||
{
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, row => { row.DomainName = "contoso.com"; return true; }, "auth", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
var created = Assert.Single(_creates);
|
||||
Assert.Equal("contoso.com", created.DomainName);
|
||||
Assert.Equal(TenantId, created.TenantId);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_RowCreatedConcurrently_DecidesAgainOnTheStoredRow()
|
||||
{
|
||||
// Our read saw no row, the insert lost (409), the re-read finds the concurrent writer's row.
|
||||
var stored = Row(r => r.DomainName = "seeded-by-the-other-login.com");
|
||||
var reads = 0;
|
||||
_repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => reads++ == 0 ? null : (stored, "etag-2"));
|
||||
_createResults.Enqueue(false);
|
||||
var seen = new List<string>();
|
||||
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, row =>
|
||||
{
|
||||
seen.Add(row.DomainName);
|
||||
return string.IsNullOrEmpty(row.DomainName);
|
||||
}, "auth", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Declined, update.Status);
|
||||
Assert.Equal(new[] { "", "seeded-by-the-other-login.com" }, seen);
|
||||
Assert.Single(_creates);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_Tombstone_IsNeverWritten()
|
||||
{
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, _ => true, "offboard", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.OffboardingInProgress, update.Status);
|
||||
_repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_NoRow_CreatesTheDefaultConditionally()
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
var config = await Service().GetConfigurationAsync(TenantId);
|
||||
|
||||
Assert.Same(Assert.Single(_creates), config);
|
||||
_repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_DefaultLostTheRace_ReturnsTheRowTheOtherWriterCreated()
|
||||
{
|
||||
// The first-login seed landed between our read and our insert: never overwrite it.
|
||||
var seeded = Row(r => r.OnboardedBy = "first@contoso.com");
|
||||
var reads = 0;
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(() => reads++ == 0 ? null : seeded);
|
||||
_createResults.Enqueue(false);
|
||||
|
||||
var config = await Service().GetConfigurationAsync(TenantId);
|
||||
|
||||
Assert.Same(seeded, config);
|
||||
Assert.Single(_creates);
|
||||
_repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
private (PlanManagementFunction Sut, Mock<IOpsEventRepository> OpsRepo, Mock<IMaintenanceRepository> Audit) Plan(
|
||||
TenantConfigurationService service)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
var sut = new PlanManagementFunction(
|
||||
NullLogger<PlanManagementFunction>.Instance, service, adminConfig.Object, audit.Object,
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object)),
|
||||
TestProConferral.Inert());
|
||||
return (sut, opsRepo, audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_ConcurrentStartWonTheRace_SecondGets409TrialAlreadyConsumed()
|
||||
{
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
_freshReads.Enqueue((Row(r => r.TrialConsumed = true), "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
var (sut, opsRepo, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, EndpointHarness.ErrorCode(response));
|
||||
Assert.Single(_replaces);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()), Times.Never);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_WarmCacheDuringTheDrainBarrier_CannotLiftTheTombstone()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row()); // this instance still sees the tenant enabled
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var (sut, _, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TenantSuspended, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(_replaces);
|
||||
_repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_Allowed_WritesTheTrialOntoTheFreshRow()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, opsRepo, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId, upn: "admin@contoso.com");
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.TrialConsumed);
|
||||
Assert.Equal("admin@contoso.com", written.TrialGrantedBy);
|
||||
Assert.Equal(written.TrialStartedUtc!.Value.AddDays(PlanManagementFunction.SelfServiceTrialDays), written.TrialExpiresUtc);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("self-service trial start", reason);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.Is<OpsEventEntry>(e => e.EventType == OpsEventTypes.TenantTrialStarted)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_WritesOnTheFreshRowUnderItsETag()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, _, audit) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, _, _) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.PayingCustomer);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(TenantId, "UPDATE", "TenantPlan", TenantId, It.IsAny<string>(),
|
||||
It.Is<Dictionary<string, string>>(d => d.ContainsKey("PayingCustomer"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_NothingChangesOnTheFreshRow_200WithoutAWrite()
|
||||
{
|
||||
_freshReads.Enqueue((Row(r => r.PayingCustomer = true), "etag-1"));
|
||||
var (sut, _, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(_replaces);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
}
|
||||
+37
-33
@@ -100,7 +100,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
// row can never be Pro, whatever the index says (fail-closed).
|
||||
var (service, repo, _) = BuildProjecting(Owner);
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync((TenantConfiguration?)null);
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>())).ReturnsAsync(true);
|
||||
|
||||
var config = await service.GetConfigurationAsync(TenantId);
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
Assert.Null(await service.GetConfigurationIfExistsAsync(TenantId));
|
||||
// MockBehavior.Strict: any SaveTenantConfigurationAsync call would have thrown.
|
||||
// MockBehavior.Strict: any write (create or replace) would have thrown.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -186,56 +186,60 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
Assert.Equal(TenantId, config.TenantId);
|
||||
}
|
||||
|
||||
// ── Save semantics (Codex finding 2026-07-07): the repository swallows storage exceptions
|
||||
// and reports failure via its bool return — the service must THROW on false so callers
|
||||
// (plan/trial endpoints, config PUT) can never audit + 200 a write that never persisted.
|
||||
// ── Write semantics: UpdateAsync is the write path (D-290). A storage failure propagates, and
|
||||
// the cache is dropped on every attempt — success or failure — so no instance that was read
|
||||
// before the write is served afterwards as if it were current.
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_RepoReportsFalse_Throws()
|
||||
private static Mock<IConfigRepository> WithStoredRow(Mock<IConfigRepository> repo)
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "alice@contoso.com" }));
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, PlanTier = "community", UpdatedBy = "x" });
|
||||
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = TenantId, PlanTier = "community", UpdatedBy = "x" }, "etag"));
|
||||
return repo;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_FailedSave_InvalidatesCachedInstance()
|
||||
public async Task UpdateAsync_StorageError_Propagates()
|
||||
{
|
||||
// The plan/trial endpoints mutate the CACHED instance in place before saving. A failed
|
||||
// save must drop that instance from the cache — otherwise the unsaved mutation is served
|
||||
// as if persisted for up to 5 minutes.
|
||||
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>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("throttled"));
|
||||
|
||||
var cached = await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
cached!.PlanTier = "enterprise"; // in-place mutation, as the plan endpoint does
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test"));
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SaveConfigurationAsync(cached));
|
||||
[Fact]
|
||||
public async Task UpdateAsync_FailedWrite_InvalidatesTheCache()
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("throttled"));
|
||||
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test"));
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must go back to storage
|
||||
|
||||
// Next read must go back to storage (cache dropped), not serve the mutated instance.
|
||||
await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_Success_InvalidatesCache()
|
||||
public async Task UpdateAsync_Success_InvalidatesTheCache_AndNeverTouchesTheCachedInstance()
|
||||
{
|
||||
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>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must re-read after save
|
||||
var cached = await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test");
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must re-read after the write
|
||||
|
||||
Assert.Equal("community", cached!.PlanTier);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class TenantContactEmailTests
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Server-side validation (UpdateTenantConfigurationFunction)
|
||||
// Server-side validation (TenantConfigValidation)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
@@ -51,7 +51,7 @@ public class TenantContactEmailTests
|
||||
[InlineData("first.last+autopilot@sub.fabrikam.co.uk")]
|
||||
[InlineData("UPPER@CONTOSO.COM")]
|
||||
public void ValidateContactEmail_accepts_real_addresses(string email)
|
||||
=> Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
=> Assert.Null(TenantConfigValidation.ValidateContactEmail(email));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
@@ -60,7 +60,7 @@ public class TenantContactEmailTests
|
||||
public void ValidateContactEmail_treats_absence_as_valid(string? email)
|
||||
{
|
||||
// No contact address is a legitimate state — it means we cannot reach this tenant.
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
Assert.Null(TenantConfigValidation.ValidateContactEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -76,7 +76,7 @@ public class TenantContactEmailTests
|
||||
[InlineData("Ops Team <ops@contoso.com>", "single address")] // display-name form
|
||||
public void ValidateContactEmail_rejects_values_that_are_not_a_single_address(string email, string expectedHint)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(email);
|
||||
var error = TenantConfigValidation.ValidateContactEmail(email);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedHint, error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public class TenantContactEmailTests
|
||||
public void ValidateContactEmail_rejects_header_injection()
|
||||
{
|
||||
// Once this address is actually mailed, an embedded CR/LF would forge mail headers.
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(
|
||||
var error = TenantConfigValidation.ValidateContactEmail(
|
||||
$"ops@contoso.com{(char)13}{(char)10}Bcc: attacker@evil.test");
|
||||
|
||||
Assert.NotNull(error);
|
||||
@@ -94,8 +94,8 @@ public class TenantContactEmailTests
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_an_over_long_value()
|
||||
{
|
||||
var local = new string('a', UpdateTenantConfigurationFunction.MaxContactEmailLength);
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail($"{local}@contoso.com");
|
||||
var local = new string('a', TenantConfigValidation.MaxContactEmailLength);
|
||||
var error = TenantConfigValidation.ValidateContactEmail($"{local}@contoso.com");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("at most", error!);
|
||||
@@ -189,9 +189,7 @@ 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);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -86,9 +86,7 @@ public class TenantEntitlementServiceTests
|
||||
// auto-create + persist a default config row.
|
||||
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);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -52,9 +52,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
alreadyDisabled.Disabled = true;
|
||||
alreadyDisabled.DisabledReason = "Offboarding in progress";
|
||||
alreadyDisabled.LastUpdated = DateTime.UtcNow.AddDays(-30);
|
||||
configRepoMock
|
||||
.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(alreadyDisabled);
|
||||
configRepoMock.BackWithRow(alreadyDisabled);
|
||||
|
||||
var cache = new Microsoft.Extensions.Caching.Memory.MemoryCache(
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions());
|
||||
@@ -196,7 +194,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.True(repo.Pointers.TryGetValue(TenantId, out var entry));
|
||||
Assert.Equal(historyRowKey, entry.Pointer.LatestHistoryRowKey);
|
||||
@@ -222,13 +220,108 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(newHistoryRowKey, repo.Pointers[TenantId].Pointer.LatestHistoryRowKey);
|
||||
Assert.Equal("Initiated", repo.Pointers[TenantId].Pointer.LatestStatus);
|
||||
Assert.Equal(3, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, false, true)] // a later round without a trial never clears it
|
||||
[InlineData(true, true, true)]
|
||||
public async Task UpsertPointer_TrialConsumed_IsStickyAcrossRounds(bool stored, bool thisRound, bool expected)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
await repo.InsertByTenantPointerAsync(new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestHistoryRowKey = "20260101000000000_" + TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = stored,
|
||||
});
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, thisRound);
|
||||
|
||||
Assert.Equal(expected, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Equal(2, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task UpsertPointer_FreshTenant_StampsTrialConsumed(bool trialConsumed)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, trialConsumed);
|
||||
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
}
|
||||
|
||||
// ── OffboardTenant (Phase 1): the consumed trial must reach the pointer before anything is deleted ──
|
||||
|
||||
private static (TenantOffboardFunction Sut, FakeOffboardingAuditRepository Repo, RecordingEnqueuer Enqueuer, Mock<IConfigRepository> ConfigRepo)
|
||||
BuildForOffboard(TenantConfiguration? liveConfig, Exception? readFailure = null)
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.BackWithRow(liveConfig).ThrowOnRead = readFailure;
|
||||
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
var enqueuer = new RecordingEnqueuer();
|
||||
var sut = new TenantOffboardFunction(
|
||||
logger: NullLogger<TenantOffboardFunction>.Instance,
|
||||
configRepo: configRepo.Object,
|
||||
tenantConfigService: new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
||||
maintenanceRepo: Mock.Of<IMaintenanceRepository>(),
|
||||
offboardingRepo: repo,
|
||||
offboardingEnqueuer: enqueuer,
|
||||
previewWhitelistService: BuildPreviewWhitelistService(configRepo));
|
||||
return (sut, repo, enqueuer, configRepo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task OffboardTenant_CarriesTheLiveTrialConsumedOntoThePointer(bool trialConsumed)
|
||||
{
|
||||
var live = TenantConfiguration.CreateDefault(TenantId);
|
||||
live.DomainName = "contoso.com";
|
||||
live.TrialConsumed = trialConsumed;
|
||||
var (sut, repo, enqueuer, _) = BuildForOffboard(live);
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Single(enqueuer.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardTenant_ConfigReadFails_RefusesBeforeAnyStateIsWritten()
|
||||
{
|
||||
// Proceeding without the read would reset the one-trial rule for the returning tenant.
|
||||
var (sut, repo, enqueuer, configRepo) = BuildForOffboard(null, new RequestFailedException(503, "Server busy"));
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Empty(repo.History);
|
||||
Assert.Empty(repo.Pointers);
|
||||
Assert.Empty(repo.Markers);
|
||||
Assert.Empty(enqueuer.Sent);
|
||||
configRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPointer_409OnInsert_FallsBackToEtagUpdate()
|
||||
{
|
||||
@@ -273,7 +366,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.NotNull(updatedPointer);
|
||||
Assert.Equal(newHistoryRowKey, updatedPointer!.LatestHistoryRowKey);
|
||||
@@ -325,7 +418,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(2, updateCalls);
|
||||
Assert.Equal(2, readIdx);
|
||||
@@ -484,7 +577,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow));
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false));
|
||||
}
|
||||
|
||||
// ── Cache-Drain-Barrier (plan v2 §2.2 / §2.3) ─────────────────────────────
|
||||
@@ -638,27 +731,42 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, cache, _) = BuildWithConfigService();
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = false;
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
existing.DomainName = "contoso.com";
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
// Seed cache with stale "Disabled=false" entry so we can prove invalidation.
|
||||
cache.Set($"tenant-config:{TenantId}", existing);
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.NotNull(saved);
|
||||
Assert.True(saved!.Disabled);
|
||||
var (saved, source, reason) = Assert.Single(store.Writes);
|
||||
Assert.True(saved.Disabled);
|
||||
Assert.Equal("Offboarding in progress", saved.DisabledReason);
|
||||
Assert.Null(saved.DisabledUntil);
|
||||
Assert.Equal("alice@contoso.com", saved.UpdatedBy);
|
||||
Assert.Equal("contoso.com", saved.DomainName); // the rest of the row survives
|
||||
Assert.Equal(("offboard", "Disabled-gate before cascade"), (source, reason));
|
||||
Assert.False(cache.TryGetValue($"tenant-config:{TenantId}", out _),
|
||||
"cache entry must be invalidated so warm reads pick up the new Disabled=true on next access");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_DecidesOnTheStoredRow_NotAStaleCachedView()
|
||||
{
|
||||
// A cached copy still says "not disabled"; the stored row is already the tombstone.
|
||||
var (sut, configRepo, cache, _) = BuildWithConfigService();
|
||||
var tombstone = TenantConfiguration.CreateDefault(TenantId);
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
var store = configRepo.BackWithRow(tombstone);
|
||||
cache.Set($"tenant-config:{TenantId}", TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_AlreadyDisabledWithSameReason_DoesNotRewrite()
|
||||
{
|
||||
@@ -666,12 +774,12 @@ public sealed class TenantOffboardFunctionTests
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = true;
|
||||
existing.DisabledReason = "Offboarding in progress";
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
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>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
// Idempotent: no write thrash on resume-clicks while the marker is already in flight.
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -681,21 +789,19 @@ public sealed class TenantOffboardFunctionTests
|
||||
// skip the enqueue. We just assert the exception propagates — the caller's
|
||||
// try/catch + 500-return is exercised by integration tests.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).ThrowOnRead =
|
||||
new InvalidOperationException("storage unavailable");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_StorageThrowsOnSave_PropagatesAsExpected()
|
||||
public async Task EnsureTenantDisabled_StorageThrowsOnWrite_PropagatesAsExpected()
|
||||
{
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage save failed"));
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).ThrowOnWrite =
|
||||
new InvalidOperationException("storage write failed");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -709,35 +815,25 @@ public sealed class TenantOffboardFunctionTests
|
||||
// The fix creates a fresh CreateDefault row with Disabled=true so the auth-gate is
|
||||
// committed even in this edge case.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
var store = configRepo.BackWithRow(null);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(TenantId, saved!.TenantId);
|
||||
var saved = Assert.Single(store.Writes).Row;
|
||||
Assert.Equal(TenantId, saved.TenantId);
|
||||
Assert.True(saved.Disabled, "fresh tombstone must carry Disabled=true");
|
||||
Assert.Equal("Offboarding in progress", saved.DisabledReason);
|
||||
Assert.Equal("alice@contoso.com", saved.UpdatedBy);
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.WroteNewTombstone, result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_SaveReturnsFalse_ThrowsAsHardFail()
|
||||
public async Task EnsureTenantDisabled_EveryWriteLost_ThrowsAsHardFail()
|
||||
{
|
||||
// PR3.B-revised Finding 2: TableConfigRepository.SaveTenantConfigurationAsync
|
||||
// returns false on transient storage failure (it does NOT throw). Ignoring that
|
||||
// return-value would let the caller proceed to enqueue without the auth-gate
|
||||
// committed. Turn it into a hard exception so the offboard is NOT enqueued.
|
||||
// Without Disabled=true the caller would proceed to enqueue without the auth-gate
|
||||
// committed: every lost conditional write is a hard exception, never a silent skip.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // transient storage rejection — NOT a throw.
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).RejectWrites = true;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -745,15 +841,10 @@ public sealed class TenantOffboardFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_NoConfigRow_SaveReturnsFalse_ThrowsAsHardFail()
|
||||
public async Task EnsureTenantDisabled_NoConfigRow_EveryWriteLost_ThrowsAsHardFail()
|
||||
{
|
||||
// Combined edge case: missing row → we'd create a fresh tombstone → Save fails →
|
||||
// hard fail (otherwise the caller would proceed without an auth-gate).
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
configRepo.BackWithRow(null).RejectWrites = true;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -766,12 +857,12 @@ public sealed class TenantOffboardFunctionTests
|
||||
var tombstone = TenantConfiguration.CreateDefault(TenantId);
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(tombstone);
|
||||
var store = configRepo.BackWithRow(tombstone);
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -780,13 +871,13 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = false;
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.WroteNewTombstone, result.Outcome);
|
||||
// The anchor of the drain-deadline math is the stored write time.
|
||||
Assert.Equal(Assert.Single(store.Writes).Row.LastUpdated, result.TombstoneLastUpdated);
|
||||
}
|
||||
|
||||
// ── Resume-path cache-drain bypass fix (Codex review) ────────────────────
|
||||
@@ -830,10 +921,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var configRepoMock = new Mock<IConfigRepository>();
|
||||
var notYetDisabled = TenantConfiguration.CreateDefault(TenantId);
|
||||
notYetDisabled.Disabled = false;
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(notYetDisabled);
|
||||
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
configRepoMock.BackWithRow(notYetDisabled);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -925,8 +1013,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = DateTime.UtcNow.AddSeconds(-1); // freshly written ~1s ago
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -1000,8 +1087,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = initiatedAt;
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -1043,8 +1129,8 @@ public sealed class TenantOffboardFunctionTests
|
||||
var historyAfter = repo.History[historyRowKey];
|
||||
Assert.Equal(originalEarliestProcessingAt, historyAfter.EarliestProcessingAt);
|
||||
|
||||
// No Save call (the tombstone was already correct).
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
// No write (the tombstone was already correct).
|
||||
configRepoMock.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ── RetryFailedOffboardingAsync (operator retry of a Failed offboarding) ──────────
|
||||
@@ -1208,7 +1294,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = DateTime.UtcNow.AddMinutes(-2);
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new TenantConfigurationService(configRepoMock.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var enqueuer = new RecordingEnqueuer();
|
||||
@@ -1227,7 +1313,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var delay = Assert.Single(enqueuer.VisibilityDelays);
|
||||
Assert.NotNull(delay);
|
||||
Assert.InRange(delay!.Value, TimeSpan.FromMinutes(3.5), TimeSpan.FromMinutes(4.1));
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
configRepoMock.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -191,6 +191,7 @@ public class TypedContractCompletionWireParityTests
|
||||
new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
totalInstalls = 4,
|
||||
succeeded = 2,
|
||||
skipped = 1,
|
||||
@@ -234,6 +235,7 @@ public class TypedContractCompletionWireParityTests
|
||||
new AppMetricsAppGroup
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
TotalInstalls = 4,
|
||||
Succeeded = 2,
|
||||
Skipped = 1,
|
||||
@@ -593,6 +595,7 @@ public class TypedContractCompletionWireParityTests
|
||||
var item = new AppsListItem
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
AppType = "Win32",
|
||||
TotalInstalls = 5,
|
||||
Succeeded = 3,
|
||||
@@ -610,6 +613,7 @@ public class TypedContractCompletionWireParityTests
|
||||
var anonymousItem = new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
appType = "Win32",
|
||||
totalInstalls = 5,
|
||||
succeeded = 3,
|
||||
@@ -677,6 +681,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
success = true,
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
appType = string.Empty,
|
||||
windowDays = 30,
|
||||
collisionExcluded = 0,
|
||||
@@ -709,6 +714,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
Success = true,
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
AppType = string.Empty,
|
||||
WindowDays = 30,
|
||||
CollisionExcluded = 0,
|
||||
|
||||
@@ -61,8 +61,7 @@ public class TypedRequestGuardTests
|
||||
private static readonly Dictionary<string, int> NewtonsoftBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/PatchAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = 2,
|
||||
@@ -157,13 +156,21 @@ public class TypedRequestGuardTests
|
||||
var isObjectMap = slot.IsGenericType && slot.GetGenericTypeDefinition() == typeof(Dictionary<,>) && slot.GetGenericArguments()[1] == typeof(object);
|
||||
if (slot == typeof(object))
|
||||
offenders.Add($"{t.Name}.{p.Name}: object slot (TypeScript gets unknown)");
|
||||
else if (isObjectMap && !(t == typeof(PatchTenantConfigurationFieldsRequest) && p.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields)))
|
||||
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the tenant-config field patch carries one by design)");
|
||||
else if (isObjectMap && !IsFieldPatchMap(t, p))
|
||||
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the config field patches carry one by design)");
|
||||
}
|
||||
}
|
||||
Assert.True(offenders.Count == 0, "Request DTO shape violations:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two field patches (tenant and admin configuration) carry a { field: value } map on purpose:
|
||||
/// each value takes its field's own JSON type and the server gates the keys against the model.
|
||||
/// </summary>
|
||||
private static bool IsFieldPatchMap(Type type, PropertyInfo property)
|
||||
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|
||||
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> HandlerSources()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The webhook SSRF gate on real loopback sockets. DNS is injected: <c>rebind.example.test</c> does
|
||||
/// not exist, so a socket reaching a listener proves it used the gate's answer rather than a
|
||||
/// second resolution.
|
||||
/// </summary>
|
||||
public class WebhookConnectGateTests
|
||||
{
|
||||
private const string Host = "rebind.example.test";
|
||||
|
||||
private sealed class CountingResolver
|
||||
{
|
||||
private readonly IPAddress[] _answer;
|
||||
public List<string> Calls { get; } = new();
|
||||
public CountingResolver(params string[] answer) => _answer = answer.Select(IPAddress.Parse).ToArray();
|
||||
|
||||
public Task<IPAddress[]> Resolve(string host, CancellationToken _)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return Task.FromResult(_answer);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient ClientThrough(Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> connect,
|
||||
X509Certificate2? trustedServerCertificate = null)
|
||||
{
|
||||
var handler = new SocketsHttpHandler { AllowAutoRedirect = false, ConnectCallback = connect };
|
||||
if (trustedServerCertificate != null)
|
||||
{
|
||||
// Pinned to the test's own self-signed certificate — validation stays on, only the trust anchor differs.
|
||||
var pinned = trustedServerCertificate.GetCertHashString();
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString() == pinned;
|
||||
}
|
||||
// Short timeout: with a broken gate the TLS handshake waits on a listener that never accepts.
|
||||
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
private static TcpListener StartListener()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateAnswer_FailsClosed_WithoutOpeningASocket()
|
||||
{
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
var resolver = new CountingResolver("127.0.0.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}:{PortOf(listener)}/hook"));
|
||||
|
||||
var refused = Assert.IsType<SsrfException>(ex.InnerException);
|
||||
Assert.Contains("private or reserved", refused.Message);
|
||||
Assert.Equal(new[] { Host }, resolver.Calls);
|
||||
Assert.False(listener.Pending());
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedAnswer_WithOnePrivateAddress_FailsClosed()
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1", "169.254.169.254");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
|
||||
Assert.IsType<SsrfException>(ex.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateIpLiteral_FailsClosed_WithoutResolving()
|
||||
{
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://127.0.0.1:{PortOf(listener)}/hook"));
|
||||
|
||||
Assert.IsType<SsrfException>(ex.InnerException);
|
||||
Assert.Empty(resolver.Calls);
|
||||
Assert.False(listener.Pending());
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainHttp_IsRefused_BeforeResolving()
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://{Host}/hook"));
|
||||
|
||||
Assert.Contains("HTTPS", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
Assert.Empty(resolver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnresolvableOrEmptyAnswer_FailsClosed()
|
||||
{
|
||||
Task<IPAddress[]> NotFound(string _, CancellationToken __) => throw new SocketException((int)SocketError.HostNotFound);
|
||||
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(NotFound)))
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
Assert.Contains("resolve", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
}
|
||||
|
||||
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(new CountingResolver().Resolve)))
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
Assert.Contains("no addresses", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowedAnswer_ConnectsToExactlyThatAddress_AndTlsStillTargetsTheRequestHost()
|
||||
{
|
||||
using var certificate = CreateServerCertificate(Host);
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
string? serverName = null;
|
||||
var server = Task.Run(async () =>
|
||||
{
|
||||
using var tcp = await listener.AcceptTcpClientAsync();
|
||||
using var tls = new SslStream(tcp.GetStream());
|
||||
await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate });
|
||||
serverName = tls.TargetHostName;
|
||||
var buffer = new byte[4096];
|
||||
var request = new StringBuilder();
|
||||
while (!request.ToString().Contains("\r\n\r\n"))
|
||||
{
|
||||
var read = await tls.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
request.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
await tls.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"));
|
||||
});
|
||||
|
||||
// The classifier is widened to reach the loopback listener; the socket path is the production one.
|
||||
var resolver = new CountingResolver("127.0.0.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve, _ => false), certificate);
|
||||
|
||||
var response = await client.GetAsync($"https://{Host}:{PortOf(listener)}/hook");
|
||||
await server;
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("ok", await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(Host, serverName);
|
||||
Assert.Equal(new[] { Host }, resolver.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebhookClient_PrimaryHandler_ConnectsOnlyThroughTheGate()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
HttpMessageHandler handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(nameof(WebhookNotificationService));
|
||||
while (handler is DelegatingHandler delegating)
|
||||
handler = delegating.InnerHandler!;
|
||||
|
||||
var primary = Assert.IsType<SocketsHttpHandler>(handler);
|
||||
Assert.Same(SsrfGuard.ConnectToPublicAddress, primary.ConnectCallback);
|
||||
Assert.False(primary.AllowAutoRedirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationPolicy_DoesNotRetryARefusedDestination()
|
||||
{
|
||||
var policies = new ResiliencePolicies(NullLoggerFactory.Instance);
|
||||
var attempts = 0;
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => policies.Notification.ExecuteAsync(() =>
|
||||
{
|
||||
attempts++;
|
||||
throw new HttpRequestException("refused", new SsrfException("Webhook URL targets a private or reserved network."));
|
||||
}));
|
||||
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestNotification_ReportsTheRefusal()
|
||||
{
|
||||
var resolver = new CountingResolver("10.0.0.8");
|
||||
var webhook = new WebhookNotificationService(ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve)),
|
||||
NullLogger<WebhookNotificationService>.Instance);
|
||||
|
||||
var result = await webhook.SendNotificationWithResultAsync($"https://{Host}/hook", WebhookProviderType.GenericJson,
|
||||
new NotificationAlert { Title = "T", Summary = "S" });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Webhook URL targets a private or reserved network.", result.Message);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateServerCertificate(string host)
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest($"CN={host}", key, HashAlgorithmName.SHA256);
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
san.AddDnsName(host);
|
||||
request.CertificateExtensions.Add(san.Build());
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
|
||||
using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
// Round-trip through PKCS#12: SChannel cannot serve a certificate whose key is ephemeral.
|
||||
return X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), null);
|
||||
}
|
||||
}
|
||||
+119
-120
@@ -3,159 +3,158 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic helpers driving cross-partition merge-pagination for global views
|
||||
/// where Azure-Tables' native (PK asc, RK asc) ordering would otherwise
|
||||
/// surface partitions one-at-a-time instead of globally newest-first.
|
||||
/// Used by ops-events (categories as partitions) and global audit logs
|
||||
/// (tenants as partitions).
|
||||
/// Newest-first paging across the partitions of one table: global ops events (categories as
|
||||
/// partitions) and the global audit log (tenants as partitions). Azure Tables pages a
|
||||
/// cross-partition query in (PK asc, RK asc) order, which would surface one partition at a time,
|
||||
/// so every page runs one PartitionKey query per partition and merges the results.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per-partition continuation anchored on RowKey (not Azure's opaque token):
|
||||
/// <c>LastRowKey</c> = the oldest RowKey we have already returned for this
|
||||
/// partition. Next fetch filters <c>RowKey gt LastRowKey</c> — natural
|
||||
/// ordering within the partition (revtick → newest-first) means this skips
|
||||
/// exactly what we showed and re-considers everything older.
|
||||
/// Invariant: every partition uses the same reverse-tick RowKey scheme, so the global order is
|
||||
/// (RowKey asc, Partition asc) and that order is the display order. The merge sorts by exactly
|
||||
/// that key and never by a resolved timestamp column — a merge key that disagrees with the
|
||||
/// RowKey inside one partition would let the watermark skip rows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why not Azure's continuation token? Cross-partition merge can drop items
|
||||
/// that were fetched but didn't survive the top-pageSize cut. If we advanced
|
||||
/// an Azure cursor past those leftovers, we'd lose them forever. With a
|
||||
/// RowKey bound we simply don't advance the continuation for partitions whose
|
||||
/// fetched items lost the merge — they get re-fetched next call and compete
|
||||
/// again at a lower floor.
|
||||
/// The continuation is the (RowKey, Partition) of the last returned row, so its size does not
|
||||
/// depend on the number of partitions. The earlier per-partition continuation map kept one entry
|
||||
/// for every partition with rows and outgrew the host's URL limit (HTTP 414) on the tenant
|
||||
/// fan-out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Continuation shape: only <em>active</em> partitions are encoded. Missing
|
||||
/// from the map = exhausted (or never had any data). For 200-tenant audit
|
||||
/// fan-outs that previously kept exhausted entries around, the encoded
|
||||
/// token would balloon past 30 KB and trip URL-length limits before
|
||||
/// reaching the function (browser <c>net::ERR_FAILED</c> on Next click).
|
||||
/// Now the token shrinks page-over-page as partitions exhaust.
|
||||
/// Each partition reads at most <c>pageSize + 1</c> rows after the watermark. More rows exist
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
internal sealed record PartitionContinuation(string? LastRowKey);
|
||||
/// <summary>The (RowKey, Partition) of the last row a page returned.</summary>
|
||||
internal sealed record Watermark(string RowKey, string Partition);
|
||||
|
||||
internal sealed record PartitionFetchResult<T>(
|
||||
string Partition,
|
||||
IReadOnlyList<(string RowKey, T Item)> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Pure merge-and-advance step. Given pre-fetched per-partition items
|
||||
/// (already newest-first within each list) and the previous per-partition
|
||||
/// continuation map, returns the top-pageSize merged items plus the
|
||||
/// next continuation map. Partitions with nothing left to return are
|
||||
/// dropped from the map entirely — consumers treat "missing" as
|
||||
/// exhausted on subsequent calls (see class remarks).
|
||||
/// </summary>
|
||||
public static (List<T> Items, Dictionary<string, PartitionContinuation> NextContinuations)
|
||||
MergeAndAdvance<T>(
|
||||
IReadOnlyList<PartitionFetchResult<T>> fetched,
|
||||
IReadOnlyDictionary<string, PartitionContinuation> priorContinuations,
|
||||
int pageSize,
|
||||
Func<T, DateTime> timestampSelector)
|
||||
/// <summary>Lower RowKey bound of one partition's read.</summary>
|
||||
internal sealed record RowKeyBound(string RowKey, bool Inclusive)
|
||||
{
|
||||
var allFetched = fetched
|
||||
.SelectMany(r => r.Items.Select(t => (part: r.Partition, rk: t.RowKey, item: t.Item)))
|
||||
.OrderByDescending(t => timestampSelector(t.item))
|
||||
.ToList();
|
||||
|
||||
var merged = allFetched.Take(pageSize).ToList();
|
||||
var returnedItems = merged.Select(t => t.item).ToList();
|
||||
|
||||
var newContinuations = new Dictionary<string, PartitionContinuation>(StringComparer.Ordinal);
|
||||
foreach (var r in fetched)
|
||||
{
|
||||
var returnedFromPart = merged.Where(m => m.part == r.Partition).ToList();
|
||||
if (returnedFromPart.Count > 0)
|
||||
{
|
||||
// Advance to the oldest RowKey we returned for this partition.
|
||||
// Revtick scheme: larger RowKey string = older timestamp, so
|
||||
// OrdinalCompare-max gives us the oldest of the returned set.
|
||||
var oldestRk = returnedFromPart
|
||||
.Select(m => m.rk)
|
||||
.OrderBy(rk => rk, StringComparer.Ordinal)
|
||||
.Last();
|
||||
newContinuations[r.Partition] = new PartitionContinuation(oldestRk);
|
||||
}
|
||||
else if (r.Items.Count == 0)
|
||||
{
|
||||
// No items returned from this partition's query → exhausted.
|
||||
// Drop from map entirely; the consumer's "missing = exhausted"
|
||||
// rule (when continuation was provided) does the right thing
|
||||
// and keeps the encoded token small.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetched some but they all lost the merge cut to other partitions.
|
||||
// Keep continuation where it was — they'll be re-fetched and likely
|
||||
// win on the next page when the floor drops. Wasted bandwidth, but
|
||||
// correct (no item silently dropped).
|
||||
priorContinuations.TryGetValue(r.Partition, out var prior);
|
||||
newContinuations[r.Partition] = prior ?? new PartitionContinuation(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (returnedItems, newContinuations);
|
||||
public string ToODataClause() => $"RowKey {(Inclusive ? "ge" : "gt")} '{RowKey.Replace("'", "''")}'";
|
||||
}
|
||||
|
||||
// Wire format v1 (legacy): every partition encoded with `{rk, x:bool}`.
|
||||
// Wire format v2 (current): only ACTIVE partitions, `{rk}` only. Decoder
|
||||
// accepts both and silently drops v1 `x:true` entries so old tokens
|
||||
// emitted by previous deploys still page through cleanly.
|
||||
private const int WireFormatVersion = 2;
|
||||
/// <summary>
|
||||
/// Delegate that reads up to <c>limit</c> rows of one partition, newest first (RowKey
|
||||
/// ascending), restricted by <c>bound</c> when it is set. It must return every matching row
|
||||
/// up to the limit — a short read is taken as "partition exhausted".
|
||||
/// </summary>
|
||||
internal delegate Task<List<(string RowKey, T Item)>> PartitionReader<T>(
|
||||
string partition, RowKeyBound? bound, int limit, CancellationToken cancellationToken);
|
||||
|
||||
public static string EncodeMultiContinuation(IReadOnlyDictionary<string, PartitionContinuation> continuations)
|
||||
/// <summary>
|
||||
/// The rows of <paramref name="partition"/> that come strictly after the watermark in
|
||||
/// (RowKey, Partition) order. A partition that sorts after the watermark's partition may still
|
||||
/// hold rows with the watermark's own RowKey; one that sorts before (or is the same) may not.
|
||||
/// </summary>
|
||||
internal static RowKeyBound? BoundAfter(Watermark? watermark, string partition)
|
||||
{
|
||||
var doc = new Dictionary<string, object?>
|
||||
if (watermark == null) return null;
|
||||
var inclusive = string.CompareOrdinal(partition, watermark.Partition) > 0;
|
||||
return new RowKeyBound(watermark.RowKey, inclusive);
|
||||
}
|
||||
|
||||
public static async Task<RawPage<T>> FetchPageAsync<T>(
|
||||
IEnumerable<string> partitions,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
PartitionReader<T> readPartition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
|
||||
Watermark? watermark = null;
|
||||
if (!string.IsNullOrEmpty(continuation))
|
||||
{
|
||||
watermark = DecodeWatermark(continuation);
|
||||
// The HMAC wrapper already rejected foreign tokens; one that passes it and still does
|
||||
// not decode was minted by an older deploy. End the list rather than restart it.
|
||||
if (watermark == null) return RawPage<T>.Empty;
|
||||
}
|
||||
|
||||
var limit = pageSize + 1;
|
||||
var rows = await BoundedFanOut.RunAsync(partitions, BoundedFanOut.CrossTenantConcurrency, async (partition, ct) =>
|
||||
{
|
||||
var read = await readPartition(partition, BoundAfter(watermark, partition), limit, ct).ConfigureAwait(false);
|
||||
return read.Select(r => (Partition: partition, r.RowKey, r.Item)).ToList();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Merge(rows, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure merge step: orders the rows read from all partitions by (RowKey, Partition), returns
|
||||
/// the first <paramref name="pageSize"/> and a continuation when rows are left over.
|
||||
/// </summary>
|
||||
internal static RawPage<T> Merge<T>(IEnumerable<(string Partition, string RowKey, T Item)> rows, int pageSize)
|
||||
{
|
||||
var ordered = rows
|
||||
.OrderBy(r => r.RowKey, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Partition, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var page = ordered.Take(pageSize).ToList();
|
||||
|
||||
string? next = null;
|
||||
if (ordered.Count > pageSize)
|
||||
{
|
||||
var last = page[page.Count - 1];
|
||||
next = EncodeWatermark(new Watermark(last.RowKey, last.Partition));
|
||||
}
|
||||
return new RawPage<T>(page.Select(r => r.Item).ToList(), next);
|
||||
}
|
||||
|
||||
private const int WireFormatVersion = 3;
|
||||
|
||||
public static string EncodeWatermark(Watermark watermark)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["v"] = WireFormatVersion,
|
||||
["c"] = continuations.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (object?)new Dictionary<string, object?>
|
||||
{
|
||||
["rk"] = kv.Value.LastRowKey,
|
||||
}),
|
||||
};
|
||||
var json = JsonSerializer.Serialize(doc);
|
||||
["rk"] = watermark.RowKey,
|
||||
["p"] = watermark.Partition,
|
||||
});
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public static Dictionary<string, PartitionContinuation> DecodeMultiContinuation(string? raw)
|
||||
/// <summary>Decodes a watermark; null for anything that is not a v3 watermark.</summary>
|
||||
public static Watermark? DecodeWatermark(string? raw)
|
||||
{
|
||||
var result = new Dictionary<string, PartitionContinuation>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrEmpty(raw)) return result;
|
||||
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(raw);
|
||||
using var doc = JsonDocument.Parse(bytes);
|
||||
if (!doc.RootElement.TryGetProperty("c", out var entries)) return result;
|
||||
foreach (var prop in entries.EnumerateObject())
|
||||
{
|
||||
// v1 back-compat: skip entries explicitly marked exhausted.
|
||||
// v2 never writes them, but old in-flight tokens may.
|
||||
if (prop.Value.TryGetProperty("x", out var xEl) && xEl.ValueKind == JsonValueKind.True)
|
||||
continue;
|
||||
using var doc = JsonDocument.Parse(Convert.FromBase64String(raw));
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!root.TryGetProperty("v", out var v) || v.ValueKind != JsonValueKind.Number
|
||||
|| !v.TryGetInt32(out var version) || version != WireFormatVersion)
|
||||
return null;
|
||||
if (!root.TryGetProperty("rk", out var rk) || rk.ValueKind != JsonValueKind.String) return null;
|
||||
if (!root.TryGetProperty("p", out var p) || p.ValueKind != JsonValueKind.String) return null;
|
||||
|
||||
string? rk = prop.Value.TryGetProperty("rk", out var rkEl) && rkEl.ValueKind == JsonValueKind.String
|
||||
? rkEl.GetString()
|
||||
: null;
|
||||
result[prop.Name] = new PartitionContinuation(rk);
|
||||
}
|
||||
var rowKey = rk.GetString();
|
||||
var partition = p.GetString();
|
||||
return string.IsNullOrEmpty(rowKey) || partition == null ? null : new Watermark(rowKey, partition);
|
||||
}
|
||||
catch
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed continuation → treat as empty (start over). The wire-
|
||||
// layer fingerprint already rejects tampered tokens before reaching here.
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-29
@@ -78,27 +78,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
|
||||
=> SaveTenantConfigurationAsync(config, backupSource: null, backupReason: null);
|
||||
|
||||
public async Task<bool> SaveTenantConfigurationAsync(
|
||||
TenantConfiguration config, string? backupSource, string? backupReason)
|
||||
public async Task<bool> TryCreateTenantConfigurationAsync(TenantConfiguration config)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
try
|
||||
{
|
||||
var entity = ConvertToTenantTableEntity(config);
|
||||
await _tenantConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
await _tenantConfigTableClient.AddEntityAsync(ConvertToTenantTableEntity(config));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving tenant configuration for {TenantId}", config.TenantId);
|
||||
return false;
|
||||
return false; // created concurrently — the caller re-reads and updates
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +123,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryReplaceTenantConfigurationAsync(
|
||||
TenantConfiguration config, string etag, string? backupSource, string? backupReason)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
return await TryReplaceTenantConfigurationAsync(config, etag);
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -327,23 +327,126 @@ 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);
|
||||
internal const int AdminUpdateMaxAttempts = 3;
|
||||
|
||||
/// <summary>Server stamps: written on every accepted change, never counted as one.</summary>
|
||||
private static readonly HashSet<string> AdminStampColumns = new(StringComparer.Ordinal)
|
||||
{
|
||||
"PartitionKey", "RowKey", "LastUpdated", "UpdatedBy",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The one write path of the admin row (D-285). Each attempt reads the raw row fresh with
|
||||
/// its ETag and materializes it twice — <c>before</c> and the <c>draft</c> the mutation edits —
|
||||
/// so the copy is exact for every persisted column. The changed columns are computed from
|
||||
/// the two converter projections and laid onto the freshly read raw entity (a null value
|
||||
/// drops the column); the conditional replace then writes exactly those plus the stamps.
|
||||
/// Every other column keeps its stored value: the release pipeline's <c>LatestAgentV2*</c>,
|
||||
/// columns of a newer build, retired columns. The former full-model replace wrote every
|
||||
/// column from whatever copy the caller held, so a portal page loaded before an agent
|
||||
/// release reverted the release's hashes on save (2026-09-25).
|
||||
/// </summary>
|
||||
public async Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
|
||||
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
|
||||
{
|
||||
if (mutate == null) throw new ArgumentNullException(nameof(mutate));
|
||||
|
||||
for (var attempt = 1; attempt <= AdminUpdateMaxAttempts; attempt++)
|
||||
{
|
||||
TableEntity? stored = null;
|
||||
try
|
||||
{
|
||||
stored = (await _adminConfigTableClient.GetEntityAsync<TableEntity>("GlobalConfig", "config")).Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No row yet: the first accepted change creates it from the defaults.
|
||||
}
|
||||
|
||||
var before = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
|
||||
var draft = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
|
||||
|
||||
var error = mutate(draft);
|
||||
if (error != null)
|
||||
return new AdminConfigurationUpdateResult { Error = error, Before = before, After = before };
|
||||
|
||||
var beforeColumns = ConvertToAdminTableEntity(before);
|
||||
var draftColumns = ConvertToAdminTableEntity(draft);
|
||||
var changed = ChangedAdminColumns(beforeColumns, draftColumns);
|
||||
if (changed.Count == 0)
|
||||
return new AdminConfigurationUpdateResult { Before = before, After = before };
|
||||
|
||||
draft.UpdatedBy = updatedBy;
|
||||
draft.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_adminConfigTableClient, "GlobalConfig", "config",
|
||||
ConvertFromAdminTableEntity, draft, AdminBackupNoiseProperties,
|
||||
updatedBy, source ?? "admin-config", reason: null);
|
||||
|
||||
try
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(draft));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var column in changed)
|
||||
{
|
||||
var value = draftColumns[column];
|
||||
if (value == null) stored.Remove(column);
|
||||
else stored[column] = value;
|
||||
}
|
||||
stored["LastUpdated"] = draft.LastUpdated;
|
||||
stored["UpdatedBy"] = draft.UpdatedBy;
|
||||
await _adminConfigTableClient.UpdateEntityAsync(stored, stored.ETag, TableUpdateMode.Replace);
|
||||
}
|
||||
|
||||
return new AdminConfigurationUpdateResult { Before = before, After = draft, ChangedColumns = changed };
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 409)
|
||||
{
|
||||
// Someone wrote (or created) the row since this attempt's read: re-read and
|
||||
// re-apply the mutation onto their state instead of overwriting it.
|
||||
_logger.LogInformation(
|
||||
"Admin configuration changed concurrently (attempt {Attempt}/{Max}) — re-reading",
|
||||
attempt, AdminUpdateMaxAttempts);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Admin configuration update lost {AdminUpdateMaxAttempts} conditional-write races — retry shortly.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Columns whose projected value differs between the two converter outputs. Both sides come
|
||||
/// from <see cref="ConvertToAdminTableEntity"/>, so value types match and plain equality holds.
|
||||
/// </summary>
|
||||
internal static List<string> ChangedAdminColumns(TableEntity before, TableEntity after)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
foreach (var column in after.Keys.Union(before.Keys, StringComparer.Ordinal))
|
||||
{
|
||||
if (AdminStampColumns.Contains(column)) continue;
|
||||
before.TryGetValue(column, out var b);
|
||||
after.TryGetValue(column, out var a);
|
||||
if (!Equals(b, a)) changed.Add(column);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public async Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = ConvertToAdminTableEntity(config);
|
||||
await _adminConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(config));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving admin configuration");
|
||||
return false;
|
||||
return false; // A row exists — never replace it with defaults.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,10 +1034,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
|
||||
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
|
||||
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
|
||||
// Per-line agent binary integrity (written by build scripts via Merge).
|
||||
// Per-line agent binary integrity: written by the release pipeline via Merge.
|
||||
// The backend never changes them (the PATCH gate rejects them), so the
|
||||
// changed-columns write (UpdateAdminConfigurationAsync) never touches them.
|
||||
// V2 is the only wired line; future V3 = add field set here. Retired columns
|
||||
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
|
||||
// so the next Save evicts them implicitly on overwrite.
|
||||
// (V1-suffix and the even older unsuffixed "LatestAgent*") are not projected.
|
||||
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
|
||||
{ "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty },
|
||||
{ "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty },
|
||||
|
||||
+12
-4
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows
|
||||
/// that are refreshed while the episode is active and deleted on re-arm)
|
||||
/// - app-version duration regression: "appversionregression|{app-lower}|{version-lower}"
|
||||
/// (non-IME channels: "appversionregression|{source}:{app-lower}|{version-lower}")
|
||||
/// (trimmed + table-key-sanitized; same payload-carrying episode semantics — raw
|
||||
/// AppName/CurrentVersion live in columns, the mapper never parses the RowKey)
|
||||
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
|
||||
@@ -309,7 +310,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAppVersionRegressionAsync(string tenantId, string appName, string currentVersion)
|
||||
public async Task DeleteAppVersionRegressionAsync(string tenantId, string source, string appName, string currentVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId)
|
||||
|| string.IsNullOrWhiteSpace(appName)
|
||||
@@ -320,7 +321,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
try
|
||||
{
|
||||
await _table.DeleteEntityAsync(
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(appName, currentVersion));
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(source, appName, currentVersion));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -576,10 +577,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// two apps share an episode row — tolerable); the raw values live in columns and the
|
||||
/// mapper reads them, never the RowKey.
|
||||
/// </summary>
|
||||
internal static string BuildAppVersionRegressionRowKey(string appName, string version)
|
||||
internal static string BuildAppVersionRegressionRowKey(string? source, string appName, string version)
|
||||
{
|
||||
var app = SanitizeTableKey((appName ?? string.Empty).Trim().ToLowerInvariant());
|
||||
var ver = SanitizeTableKey((version ?? string.Empty).Trim().ToLowerInvariant());
|
||||
// IME keys predate channels and keep their shape; other channels prefix the app part.
|
||||
var channel = AppInstallSources.Normalize(source);
|
||||
if (channel != AppInstallSources.Ime)
|
||||
app = $"{SanitizeTableKey(channel)}:{app}";
|
||||
return $"{AppVersionRegressionRowKeyPrefix}{app}|{ver}";
|
||||
}
|
||||
|
||||
@@ -598,10 +603,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static TableEntity BuildAppVersionRegressionEntity(string tenantId, AppVersionRegressionAlert alert)
|
||||
{
|
||||
return new TableEntity(
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.AppName, alert.CurrentVersion))
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.Source, alert.AppName, alert.CurrentVersion))
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["AppName"] = alert.AppName,
|
||||
["Source"] = AppInstallSources.Normalize(alert.Source),
|
||||
["CurrentVersion"] = alert.CurrentVersion,
|
||||
["PreviousVersion"] = alert.PreviousVersion ?? string.Empty,
|
||||
["CurrentMedianSeconds"] = alert.CurrentMedianSeconds,
|
||||
@@ -620,6 +626,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
|
||||
AppName = entity.GetString("AppName") ?? string.Empty,
|
||||
// Rows written before channels existed are IME episodes.
|
||||
Source = AppInstallSources.Normalize(entity.GetString("Source")),
|
||||
CurrentVersion = entity.GetString("CurrentVersion") ?? string.Empty,
|
||||
PreviousVersion = entity.GetString("PreviousVersion") ?? string.Empty,
|
||||
CurrentMedianSeconds = entity.GetInt32("CurrentMedianSeconds") ?? 0,
|
||||
|
||||
+3
@@ -282,6 +282,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["LatestStatus"] = p.LatestStatus,
|
||||
["LatestUpdatedAt"] = p.LatestUpdatedAt,
|
||||
["OffboardCount"] = p.OffboardCount,
|
||||
["TrialConsumed"] = p.TrialConsumed,
|
||||
};
|
||||
|
||||
private static OffboardingByTenantPointer MapByTenantPointer(TableEntity e) => new()
|
||||
@@ -293,6 +294,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LatestStatus = e.GetString("LatestStatus") ?? "Initiated",
|
||||
LatestUpdatedAt = e.GetDateTime("LatestUpdatedAt") ?? default,
|
||||
OffboardCount = e.GetInt32("OffboardCount") ?? 1,
|
||||
// Absent on pointers from before the column; false is correct for all of them (D-288).
|
||||
TrialConsumed = e.GetBoolean("TrialConsumed") ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+16
-41
@@ -115,60 +115,35 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// partitions would just trade certainty for cost — the coverage test guards the list instead.
|
||||
internal static readonly string[] AllCategories = OpsEventCategory.All;
|
||||
|
||||
private async Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
|
||||
private Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation);
|
||||
// First-page request fans out across every category; subsequent
|
||||
// pages restrict to whatever is still active in the continuation
|
||||
// map. With only 6 fixed categories this never grew to a problem
|
||||
// size, but the convention now matches the audit fan-out so the
|
||||
// shared fan-out helper has one rule.
|
||||
var isFirstPage = string.IsNullOrEmpty(continuation);
|
||||
|
||||
var activeCats = AllCategories
|
||||
.Where(cat => isFirstPage || continuations.ContainsKey(cat))
|
||||
.ToList();
|
||||
if (activeCats.Count == 0)
|
||||
return new RawPage<OpsEventEntry>(new List<OpsEventEntry>(), null);
|
||||
|
||||
var fetchTasks = activeCats.Select(async cat =>
|
||||
{
|
||||
continuations.TryGetValue(cat, out var catContinuation);
|
||||
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey, filters);
|
||||
|
||||
var fetched = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: pageSize))
|
||||
return PerPartitionFanOutMerge.FetchPageAsync<OpsEventEntry>(
|
||||
AllCategories, pageSize, continuation,
|
||||
async (category, bound, limit, ct) =>
|
||||
{
|
||||
fetched.Add((e.RowKey, MapToEntry(e)));
|
||||
if (fetched.Count >= pageSize) break;
|
||||
}
|
||||
return new PerPartitionFanOutMerge.PartitionFetchResult<OpsEventEntry>(cat, fetched);
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(fetchTasks);
|
||||
|
||||
var (items, nextContinuations) = PerPartitionFanOutMerge.MergeAndAdvance(
|
||||
results, continuations, pageSize, e => e.Timestamp);
|
||||
|
||||
// Map only carries active partitions; empty = pagination complete.
|
||||
string? nextRawToken = nextContinuations.Count > 0
|
||||
? PerPartitionFanOutMerge.EncodeMultiContinuation(nextContinuations)
|
||||
: null;
|
||||
return new RawPage<OpsEventEntry>(items, nextRawToken);
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
|
||||
internal static string BuildFilterWithRowKeyBound(
|
||||
string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey,
|
||||
string category, DateTime? dateFrom, DateTime? dateTo, PerPartitionFanOutMerge.RowKeyBound? rowKeyBound,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
$"PartitionKey eq '{category.Replace("'", "''")}'",
|
||||
};
|
||||
if (!string.IsNullOrEmpty(lastRowKey))
|
||||
clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'");
|
||||
if (rowKeyBound != null)
|
||||
clauses.Add(rowKeyBound.ToODataClause());
|
||||
if (dateFrom.HasValue)
|
||||
clauses.Add(BusinessTimestamp.OpsDateFromClause(ToUtc(dateFrom.Value)));
|
||||
if (dateTo.HasValue)
|
||||
|
||||
@@ -100,19 +100,22 @@ public class TenantOffboardFunction
|
||||
"TENANT OFFBOARD initiated for tenant {TenantId} by {Upn}",
|
||||
normalizedTenantId, upn);
|
||||
|
||||
// 2. Capture DomainName from TenantConfiguration so the worker's Completed/Failed
|
||||
// OpsEvents can render "{domain} ({tenantId})" instead of the bare GUID.
|
||||
string? domainName = null;
|
||||
// 2. Capture what must outlive the TenantConfiguration row the cascade deletes: DomainName
|
||||
// for the worker's Completed/Failed OpsEvents ("{domain} ({tenantId})"), and TrialConsumed
|
||||
// for the pointer, which carries it to the returning tenant's first login (D-288).
|
||||
// Fail-loud: without the read the one-trial-per-tenant rule would silently reset.
|
||||
string? domainName;
|
||||
bool trialConsumed;
|
||||
try
|
||||
{
|
||||
var existingConfig = await _configRepo.GetTenantConfigurationAsync(targetTenantId);
|
||||
domainName = existingConfig?.DomainName;
|
||||
trialConsumed = existingConfig?.TrialConsumed ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to read tenant configuration for {TenantId} before offboard; proceeding without domain name",
|
||||
normalizedTenantId);
|
||||
_logger.LogError(ex, "Failed to read tenant configuration for {TenantId} before offboard", normalizedTenantId);
|
||||
return await Build500Async(req, "Failed to read tenant configuration");
|
||||
}
|
||||
|
||||
// 2.b — Capture the Preview-Notification-Email before Phase 2.D wipes the
|
||||
@@ -184,14 +187,14 @@ public class TenantOffboardFunction
|
||||
// 5. Upsert ByTenant pointer with ETag-CAS so OffboardCount increments race-safely.
|
||||
try
|
||||
{
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now);
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now, trialConsumed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// History row is already committed — operator can find it under
|
||||
// PartitionKey="OffboardingHistory" and retry manually. Graceful degradation
|
||||
// (plan §4.4): pointer is the re-onboarding index; without it PR4's auto-wipe
|
||||
// is no-op but the offboarding itself still proceeds.
|
||||
// PartitionKey="OffboardingHistory". Nothing destructive has started: without the
|
||||
// pointer the returning tenant would get its trial back, so refuse and let the
|
||||
// admin re-click.
|
||||
_logger.LogError(ex,
|
||||
"Failed to upsert OffboardingByTenant pointer for {TenantId} after history row inserted",
|
||||
normalizedTenantId);
|
||||
@@ -722,7 +725,7 @@ public class TenantOffboardFunction
|
||||
/// <para>
|
||||
/// Drain-deadline math the caller applies:
|
||||
/// <c>effectiveDeadline = max(history.EarliestProcessingAt, TombstoneLastUpdated + DrainBarrier)</c>.
|
||||
/// Because the tombstone's <c>LastUpdated</c> is part of the same atomic Save as the
|
||||
/// Because the tombstone's <c>LastUpdated</c> is part of the same atomic write as the
|
||||
/// Disabled-flip, it is always present once the auth-gate is committed — even if
|
||||
/// the History-row patch later fails on a fast re-click.
|
||||
/// </para>
|
||||
@@ -734,7 +737,7 @@ public class TenantOffboardFunction
|
||||
internal enum EnsureDisabledOutcome
|
||||
{
|
||||
/// <summary>Row was already a Disabled=true tombstone with the offboarding reason —
|
||||
/// no Save was issued. Existing <c>LastUpdated</c> is returned.</summary>
|
||||
/// nothing was written. Existing <c>LastUpdated</c> is returned.</summary>
|
||||
AlreadyTombstone,
|
||||
/// <summary>We actively wrote the Disabled=true gate (either flipped an existing
|
||||
/// row or created a fresh one). <c>LastUpdated</c> is the timestamp of this write.</summary>
|
||||
@@ -759,70 +762,54 @@ public class TenantOffboardFunction
|
||||
/// <para>
|
||||
/// Behaviour:
|
||||
/// <list type="bullet">
|
||||
/// <item>Existing TenantConfiguration row → read-modify-write so signup data + custom
|
||||
/// rate limits etc. are preserved. Idempotent: if already
|
||||
/// <c>Disabled=true</c> with the offboarding reason, no Save is issued and
|
||||
/// <item>Existing TenantConfiguration row → conditional write on the fresh row
|
||||
/// (<see cref="TenantConfigurationService.CreateOrUpdateAsync"/>) so signup data + custom
|
||||
/// rate limits etc. are preserved and no concurrent write is rewound. Idempotent: if already
|
||||
/// <c>Disabled=true</c> with the offboarding reason, nothing is written and
|
||||
/// <see cref="EnsureDisabledResult.AlreadyTombstone"/> is returned.</item>
|
||||
/// <item>Missing TenantConfiguration row (edge case: very old tenant / manual delete)
|
||||
/// → create a fresh <c>CreateDefault</c> row with <c>Disabled=true</c>. Necessary
|
||||
/// because <c>AuthFunction</c> would otherwise auto-create a <c>Disabled=false</c>
|
||||
/// → create a fresh <c>CreateDefault</c> row with <c>Disabled=true</c> (conditional insert).
|
||||
/// Necessary because <c>AuthFunction</c> would otherwise auto-create a <c>Disabled=false</c>
|
||||
/// default on the next <c>/api/auth/me</c> and slip the offboarded tenant back through.</item>
|
||||
/// <item><c>SaveTenantConfigurationAsync</c> returns <c>false</c> on transient storage
|
||||
/// failure (the production repo does NOT throw; see Codex-finding TableConfigRepository).
|
||||
/// We turn that into <see cref="InvalidOperationException"/> so the caller treats it
|
||||
/// as a hard fail.</item>
|
||||
/// <item>Every conditional write lost to concurrent changes, or storage failed →
|
||||
/// throws, so the caller treats it as a hard fail.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal async Task<EnsureDisabledResult> EnsureTenantDisabledAsync(string targetTenantId, string upn)
|
||||
{
|
||||
var tenantConfig = await _configRepo.GetTenantConfigurationAsync(targetTenantId);
|
||||
|
||||
if (tenantConfig != null)
|
||||
var update = await _tenantConfigService.CreateOrUpdateAsync(targetTenantId, row =>
|
||||
{
|
||||
// Idempotent: if Disabled is already true with our reason, no-op the write so
|
||||
// resume-clicks do not thrash the LastUpdated timestamp on the row.
|
||||
var alreadyDisabled = tenantConfig.Disabled
|
||||
&& string.Equals(tenantConfig.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
|
||||
if (alreadyDisabled)
|
||||
if (string.IsNullOrEmpty(row.DomainName))
|
||||
{
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.AlreadyTombstone, tenantConfig.LastUpdated);
|
||||
// Missing-row edge case: the default is written as the tombstone so AuthFunction's
|
||||
// auto-create-default cannot bring the tenant back enabled.
|
||||
_logger.LogWarning(
|
||||
"TenantOffboard: tenant configuration for {TenantId} has no domain (missing or never onboarded) — writing the Disabled=true tombstone onto it",
|
||||
targetTenantId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Missing-row edge case: create a fresh Disabled=true row so AuthFunction's
|
||||
// auto-create-default cannot bring the tenant back enabled.
|
||||
tenantConfig = TenantConfiguration.CreateDefault(targetTenantId);
|
||||
_logger.LogWarning(
|
||||
"TenantOffboard: no TenantConfiguration row found for {TenantId} — creating fresh Disabled=true tombstone",
|
||||
targetTenantId);
|
||||
}
|
||||
row.Disabled = true;
|
||||
row.DisabledReason = OffboardingDisabledReason;
|
||||
row.DisabledUntil = null;
|
||||
row.UpdatedBy = upn;
|
||||
return true;
|
||||
}, "offboard", "Disabled-gate before cascade");
|
||||
|
||||
tenantConfig.Disabled = true;
|
||||
tenantConfig.DisabledReason = OffboardingDisabledReason;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
// Explicit LastUpdated stamp — we bypass TenantConfigurationService.SaveConfigurationAsync
|
||||
// (which would set this) and write to the repo directly, so we own the bookkeeping.
|
||||
// Critical for the resume-path drain-deadline math: this timestamp is the
|
||||
// authoritative "when did the cache-drain barrier start" anchor.
|
||||
var writtenAt = DateTime.UtcNow;
|
||||
tenantConfig.LastUpdated = writtenAt;
|
||||
|
||||
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig, "offboard", "Disabled-gate before cascade");
|
||||
if (!saved)
|
||||
switch (update.Status)
|
||||
{
|
||||
// TableConfigRepository.SaveTenantConfigurationAsync returns false on transient
|
||||
// storage failure instead of throwing. Without throwing here, the caller would
|
||||
// proceed to enqueue without the auth-gate committed — defeating the whole
|
||||
// cache-drain barrier. Surface as a hard failure.
|
||||
throw new InvalidOperationException(
|
||||
$"SaveTenantConfigurationAsync returned false for tenant {targetTenantId}; Disabled-gate NOT committed");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// Idempotent: the tombstone is already in place — nothing written, so LastUpdated
|
||||
// stays the authoritative "when did the cache-drain barrier start" anchor.
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.AlreadyTombstone, update.Config!.LastUpdated);
|
||||
case TenantConfigUpdateStatus.Updated:
|
||||
// LastUpdated is stamped by the write itself — the anchor of the drain-deadline math.
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.WroteNewTombstone, update.Config!.LastUpdated);
|
||||
default:
|
||||
// Without Disabled=true the cache-drain barrier offers no protection: hard fail.
|
||||
throw new InvalidOperationException(
|
||||
$"Disabled-gate NOT committed for tenant {targetTenantId} ({update.Status})");
|
||||
}
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.WroteNewTombstone, writtenAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -869,11 +856,10 @@ public class TenantOffboardFunction
|
||||
}
|
||||
|
||||
// Plan §4.4 read-modify-write CAS-loop. Bounded retry; throws on exhaustion so the
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index — failure
|
||||
// here is recoverable (graceful degradation in PR4) but we still surface it so the
|
||||
// operator can investigate.
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index; TrialConsumed
|
||||
// is sticky across rounds (OR), never reset by an offboarding without a trial.
|
||||
internal async Task UpsertPointerWithCasAsync(
|
||||
string normalizedTenantId, string historyRowKey, DateTime now)
|
||||
string normalizedTenantId, string historyRowKey, DateTime now, bool trialConsumed)
|
||||
{
|
||||
for (var attempt = 1; attempt <= PointerCasMaxAttempts; attempt++)
|
||||
{
|
||||
@@ -890,6 +876,7 @@ public class TenantOffboardFunction
|
||||
LatestStatus = "Initiated",
|
||||
LatestUpdatedAt = now,
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
};
|
||||
|
||||
try
|
||||
@@ -910,6 +897,7 @@ public class TenantOffboardFunction
|
||||
existing.LatestStatus = "Initiated";
|
||||
existing.LatestUpdatedAt = now;
|
||||
existing.OffboardCount = existing.OffboardCount + 1;
|
||||
existing.TrialConsumed |= trialConsumed;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -30,6 +30,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return Guid.TryParse(raw, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the optional <c>?source=</c> install channel of the per-app endpoints. Absent means
|
||||
/// <see cref="AppInstallSources.Ime"/>, the channel every app link carried before channels
|
||||
/// existed. Returns <c>false</c> for an unknown value (caller should emit a 400).
|
||||
/// </summary>
|
||||
public static bool TryParseSourceQueryParam(string? raw, out string source)
|
||||
{
|
||||
source = string.IsNullOrEmpty(raw) ? AppInstallSources.Ime : raw!.Trim().ToLowerInvariant();
|
||||
return AppInstallSources.IsKnown(source);
|
||||
}
|
||||
|
||||
// ── Opt-in pagination ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Upper bound for a single <c>apps/list</c> page when the caller opts into pagination.</summary>
|
||||
@@ -141,7 +152,9 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
// they are excluded from every per-app group and disclosed via collisionExcluded.
|
||||
var collisionExcluded = summaries.Count(s => s.AppIdCollision);
|
||||
|
||||
var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
||||
// One group per (channel, name): an Intune app and a RealmJoin package that share a
|
||||
// display name are different installs and stay separate rows.
|
||||
var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g =>
|
||||
{
|
||||
// PR0 classification (see MetricsMath.IsSkipTerminalState / HasMeasuredDuration):
|
||||
// skips leave the rate + durations; duration stats read measured rows only.
|
||||
@@ -159,7 +172,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
return new AppsListItem
|
||||
{
|
||||
AppName = g.Key,
|
||||
AppName = g.Key.AppName,
|
||||
Source = g.Key.Source,
|
||||
AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
@@ -178,6 +192,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
.OrderByDescending(a => a.Failed)
|
||||
.ThenByDescending(a => a.FailureRate)
|
||||
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
||||
.ThenBy(a => a.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
||||
@@ -225,6 +240,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
string source,
|
||||
int days,
|
||||
IReadOnlyList<AppVersionRegressionAlert>? versionRegressions = null)
|
||||
{
|
||||
@@ -238,6 +254,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var inWindow = allSummaries
|
||||
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(s.Source) == source
|
||||
&& s.StartedAt >= cutoff)
|
||||
.ToList();
|
||||
|
||||
@@ -252,6 +269,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
AppType = string.Empty,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
@@ -413,6 +431,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
AppType = appType,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
@@ -449,6 +468,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
string source,
|
||||
int days,
|
||||
string statusFilter,
|
||||
string? modelFilter,
|
||||
@@ -460,6 +480,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = allSummaries
|
||||
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(s.Source) == source
|
||||
&& s.StartedAt >= cutoff)
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -48,14 +49,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
// Active duration-regression episodes for this app (fail-soft: empty on error).
|
||||
var versionRegressions = (await _notificationTracker.GetAppVersionRegressionsAsync(tenantId))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(a.Source) == source)
|
||||
.ToList();
|
||||
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days, versionRegressions);
|
||||
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -47,6 +48,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
@@ -58,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days,
|
||||
summaries, _sessionRepo, decodedAppName, source, days,
|
||||
statusFilter, modelFilter, versionFilter, offset, limit);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+7
-2
@@ -53,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
|
||||
@@ -64,10 +68,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var versionRegressions = string.IsNullOrEmpty(scopedTenantId)
|
||||
? new List<AppVersionRegressionAlert>()
|
||||
: (await _notificationTracker.GetAppVersionRegressionsAsync(scopedTenantId!))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(a.Source) == source)
|
||||
.ToList();
|
||||
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days, versionRegressions);
|
||||
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -52,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
@@ -67,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
|
||||
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days,
|
||||
summaries, _sessionRepo, decodedAppName, source, days,
|
||||
statusFilter, modelFilter, versionFilter, offset, limit);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
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>
|
||||
/// PATCH global/config — the portal's only write to the platform configuration (D-285). The body
|
||||
/// names just the fields the operator changed; the repository re-reads the row, applies them and
|
||||
/// writes only the columns whose value actually changed. It replaced a full-model PUT that wrote
|
||||
/// every column from the page's copy, so a page loaded before an agent release reverted the
|
||||
/// release's hashes on save (2026-09-25).
|
||||
/// </summary>
|
||||
public class PatchAdminConfigurationFunction
|
||||
{
|
||||
private const int MaxBodyBytes = 1_048_576;
|
||||
|
||||
private readonly ILogger<PatchAdminConfigurationFunction> _logger;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public PatchAdminConfigurationFunction(
|
||||
ILogger<PatchAdminConfigurationFunction> logger,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fields no patch may write: the server stamps, the columns only the agent release pipeline
|
||||
/// writes (build-agent.yml / publish-scripts.yml), and the ones owned by their own flow
|
||||
/// (plan-tier endpoint, vulnerability-sync bookkeeping).
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> DeniedFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LastUpdated", "UpdatedBy",
|
||||
"LatestAgentV2Version", "LatestAgentV2Sha256", "LatestAgentV2ExeSha256", "LatestBootstrapV2ScriptVersion",
|
||||
"PlanTierDefinitionsJson",
|
||||
"VulnerabilityDataLastSyncUtc", "MsrcLastSyncUtc", "NvdCacheLastRefreshUtc", "EpssLastSyncUtc",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every persisted, writable AdminConfiguration property except <see cref="DeniedFields"/>.
|
||||
/// Derived from the entity converter, so a new column is patchable the moment it is persisted
|
||||
/// and a model property that is never stored cannot be "saved" into nothing.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyCollection<string> PatchableFields = BuildPatchableFields();
|
||||
|
||||
private static HashSet<string> BuildPatchableFields()
|
||||
{
|
||||
var writable = new HashSet<string>(
|
||||
typeof(AdminConfiguration)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => p.Name),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new HashSet<string>(
|
||||
TableConfigRepository.ConvertToAdminTableEntity(AdminConfiguration.CreateDefault()).Keys
|
||||
.Where(c => c != "PartitionKey" && c != "RowKey" && writable.Contains(c) && !DeniedFields.Contains(c)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Function("PatchAdminConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/config")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
// Newtonsoft on purpose: the field map is applied with JsonConvert.PopulateObject, like the
|
||||
// tenant field patch (TypedRequestGuardTests baseline).
|
||||
var read = await req.ReadNewtonsoftAsync<PatchAdminConfigurationRequest>(MaxBodyBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var request = read.Value!;
|
||||
if (request.Fields == null || request.Fields.Count == 0)
|
||||
{
|
||||
return await req.BadRequestAsync(
|
||||
"Body must be { \"fields\": { <fieldName>: <value>, ... } } with at least one field.");
|
||||
}
|
||||
|
||||
var fields = JObject.FromObject(request.Fields);
|
||||
var gateError = CheckFields(fields);
|
||||
if (gateError != null) return await req.BadRequestAsync(gateError);
|
||||
|
||||
var result = await _adminConfigService.UpdateAsync(
|
||||
draft => ApplyFields(draft, fields), userIdentifier, "portal");
|
||||
if (result.Error != null) return await req.BadRequestAsync(result.Error);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PatchAdminConfiguration by {User}: {Count} field(s) sent, changed: {Columns}",
|
||||
userIdentifier, fields.Count, string.Join(", ", result.ChangedColumns));
|
||||
|
||||
if (result.ChangedColumns.Count > 0)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
Constants.AuditGlobalTenantId,
|
||||
"UPDATE",
|
||||
"AdminConfiguration",
|
||||
"GlobalConfig",
|
||||
userIdentifier,
|
||||
ConfigDiffHelper.GetChanges(result.Before, result.After));
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = result.ChangedColumns.Count > 0
|
||||
? "Admin configuration updated successfully"
|
||||
: "No changes — nothing was written",
|
||||
Config = result.After,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "PatchAdminConfiguration");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The field gate, before any storage work: every key must be a patchable field, named in the
|
||||
/// error when it is not so the caller can correct itself.
|
||||
/// </summary>
|
||||
internal static string? CheckFields(JObject fields)
|
||||
{
|
||||
foreach (var prop in fields.Properties())
|
||||
{
|
||||
if (DeniedFields.Contains(prop.Name))
|
||||
{
|
||||
return $"Field \"{prop.Name}\" is not writable here (server stamp, owned by the agent release " +
|
||||
"pipeline, or written by its own flow).";
|
||||
}
|
||||
if (!PatchableFields.Contains(prop.Name))
|
||||
return $"Unknown field \"{prop.Name}\".";
|
||||
|
||||
// The ops channel list carries per-channel redacted destinations that are restored below;
|
||||
// anywhere else the placeholder would overwrite a real secret with the sentinel.
|
||||
if (!prop.Name.Equals(nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value is JValue { Type: JTokenType.String, Value: string text }
|
||||
&& text.Contains(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal))
|
||||
{
|
||||
return $"Field \"{prop.Name}\" carries the \"{Constants.RedactedSecretPlaceholder}\" placeholder — " +
|
||||
"a redacted read must never be written back.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the gated fields onto the repository's fresh draft and validates the result. JSON
|
||||
/// null clears a nullable field; camelCase keys bind case-insensitively to the model.
|
||||
/// </summary>
|
||||
internal static string? ApplyFields(AdminConfiguration draft, JObject fields)
|
||||
{
|
||||
var storedChannels = draft.OpsNotificationChannelsJson;
|
||||
try
|
||||
{
|
||||
JsonConvert.PopulateObject(fields.ToString(), draft, new JsonSerializerSettings
|
||||
{
|
||||
MissingMemberHandling = MissingMemberHandling.Error,
|
||||
});
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Never ex.Message: Newtonsoft quotes the offending VALUE, which may be a secret.
|
||||
var path = (ex as JsonSerializationException)?.Path ?? (ex as JsonReaderException)?.Path;
|
||||
return string.IsNullOrEmpty(path)
|
||||
? "A value has the wrong JSON type for its field."
|
||||
: $"The value for \"{path}\" has the wrong JSON type.";
|
||||
}
|
||||
|
||||
// Rate limits must be positive: a zero/negative value would throttle every request
|
||||
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
|
||||
var rateLimitError =
|
||||
draft.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
|
||||
draft.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
|
||||
draft.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
|
||||
draft.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
|
||||
null;
|
||||
if (rateLimitError != null)
|
||||
return $"{rateLimitError} must be at least 1 request per minute.";
|
||||
|
||||
if (fields.Properties().Any(p => p.Name.Equals(
|
||||
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
|
||||
// chat ID or webhook URL would silently break every alert, so restore per channel id.
|
||||
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
draft.OpsNotificationChannelsJson, storedChannels);
|
||||
|
||||
// Same structural gate the tenant channel list passes. No GA gate on Telegram here:
|
||||
// this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
return $"Invalid ops notification channels: {channelsError}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -17,15 +17,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
/// <summary>
|
||||
/// Transactional field-level patch of a tenant's configuration (TenantAdminOrGA: a tenant
|
||||
/// admin patches their own row on the stricter TenantAdmin caller tier — GA-only fields are
|
||||
/// an explicit 400 there). 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, GA-only).
|
||||
/// an explicit 400 there). The one client write path of the tenant configuration (the
|
||||
/// full-model PUT is gone, D-290): it 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, GA-only).
|
||||
/// </summary>
|
||||
public class PatchTenantConfigurationFieldsFunction
|
||||
{
|
||||
internal const int MaxBodyBytes = 65_536;
|
||||
|
||||
/// <summary>
|
||||
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
|
||||
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name the
|
||||
/// path (<see cref="OpsEventService"/> raises its own event type for it). Allow-listed,
|
||||
/// never echoed from arbitrary input.
|
||||
/// </summary>
|
||||
internal const string CollectLogsIntent = "collect-logs";
|
||||
internal const string CollectLogsSource = "portal-collect-logs";
|
||||
|
||||
private readonly ILogger<PatchTenantConfigurationFieldsFunction> _logger;
|
||||
private readonly TenantConfigPatchService _patchService;
|
||||
|
||||
@@ -86,12 +96,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tags the backup Source with the write path (MCP tools stamp X-Client-Source: mcp).</summary>
|
||||
/// <summary>
|
||||
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
|
||||
/// X-Client-Source: mcp) or the plain API.
|
||||
/// </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)
|
||||
{
|
||||
if (operation == "patch" && string.Equals(req.Query["intent"], CollectLogsIntent, StringComparison.OrdinalIgnoreCase))
|
||||
return CollectLogsSource;
|
||||
return 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)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -198,33 +199,36 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var changes = new Dictionary<string, string>();
|
||||
var wasPermanentPro = false;
|
||||
TenantEdition editionBefore = default, editionAfter = default;
|
||||
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
var wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||
var (editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(config, payingProvided, newPayingCustomer, changes);
|
||||
|
||||
if (changes.Count > 0)
|
||||
// Decided on the fresh row and written under its ETag; the mutation re-runs on a lost race.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
config.UpdatedBy = caller;
|
||||
// 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, "plan", "plan tier change");
|
||||
changes.Clear();
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(row.PlanTier);
|
||||
(editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(row, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(row, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(row, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(row, payingProvided, newPayingCustomer, changes);
|
||||
if (changes.Count == 0)
|
||||
return false;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
@@ -403,10 +407,27 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Pro.
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -453,6 +474,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant — also across offboarding and re-onboarding
|
||||
/// (the offboarding pointer carries <see cref="TenantConfiguration.TrialConsumed"/> over).
|
||||
/// 409 when the trial was already consumed or the tenant is already effectively Pro.
|
||||
/// </summary>
|
||||
[Function("StartTenantTrial")]
|
||||
public async Task<HttpResponseData> StartTrial(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
||||
@@ -464,29 +491,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
(string Code, string Message)? deny = null;
|
||||
|
||||
if (EvaluateTrialStart(config, nowUtc) is { } deny)
|
||||
// The verdict runs on the fresh row inside the conditional write: two concurrent
|
||||
// starts cannot both see TrialConsumed=false, and a stale cached row is never saved.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, deny.Code, deny.Message);
|
||||
}
|
||||
deny = EvaluateTrialStart(row, nowUtc);
|
||||
if (deny != null)
|
||||
return false;
|
||||
row.TrialStartedUtc = nowUtc;
|
||||
row.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
row.TrialConsumed = true;
|
||||
row.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
row.ProDowngradedUtc = null;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
config.TrialStartedUtc = nowUtc;
|
||||
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
config.TrialConsumed = true;
|
||||
config.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
config.ProDowngradedUtc = null;
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start");
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
var config = update.Config!;
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -500,8 +529,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{ "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
|
||||
});
|
||||
|
||||
// The conversion moment. Fired after the write persisted (SaveConfigurationAsync
|
||||
// is fail-loud), so an alert never announces a trial that was not stored.
|
||||
// The conversion moment. Fired only after the conditional write landed (UpdateAsync
|
||||
// returned Updated), so an alert never announces a trial that was not stored.
|
||||
await _opsEvents.RecordTenantTrialStartedAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
config.DomainName,
|
||||
@@ -602,9 +631,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
foreach (var tier in body.Tiers)
|
||||
tier.Name = tier.Name.ToLowerInvariant();
|
||||
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers);
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
var tiersJson = JsonSerializer.Serialize(body.Tiers);
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.PlanTierDefinitionsJson = tiersJson; return null; },
|
||||
TenantHelper.GetUserIdentifier(req), "plan-tiers");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers, Catalog = BuildUsagePlanCatalog() });
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class UpdateAdminConfigurationFunction
|
||||
{
|
||||
private readonly ILogger<UpdateAdminConfigurationFunction> _logger;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public UpdateAdminConfigurationFunction(
|
||||
ILogger<UpdateAdminConfigurationFunction> logger,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
[Function("UpdateAdminConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "global/config")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
_logger.LogInformation($"UpdateAdminConfiguration by Global Admin user {userIdentifier}");
|
||||
|
||||
// Parse request body
|
||||
// Newtonsoft on purpose: same configuration stack as the tenant PUT — TypedRequestGuardTests baseline.
|
||||
var read = await req.ReadNewtonsoftAsync<AdminConfiguration>(1_048_576);
|
||||
if (read.Error != null) return read.Error;
|
||||
var config = read.Value!;
|
||||
|
||||
// Rate limits must be positive: a zero/negative value would throttle every request
|
||||
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
|
||||
var rateLimitError =
|
||||
config.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
|
||||
config.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
|
||||
config.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
|
||||
config.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
|
||||
null;
|
||||
if (rateLimitError != null)
|
||||
{
|
||||
return await req.BadRequestAsync($"{rateLimitError} must be at least 1 request per minute.");
|
||||
}
|
||||
|
||||
// Set the actual user identifier for audit logging
|
||||
config.UpdatedBy = userIdentifier;
|
||||
|
||||
// Ensure PartitionKey and RowKey are correct
|
||||
config.PartitionKey = "GlobalConfig";
|
||||
config.RowKey = "config";
|
||||
|
||||
// Load existing config for diff before saving
|
||||
var existingConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
// Ops channels: a GlobalReader is served a redacted copy (destinations replaced
|
||||
// with the ***REDACTED*** sentinel). Only a GA can reach this endpoint, so this is
|
||||
// defense-in-depth — but persisting the literal sentinel as a chat ID or webhook
|
||||
// URL would silently break every alert, so restore per channel id first.
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
|
||||
config.OpsNotificationChannelsJson, existingConfig.OpsNotificationChannelsJson);
|
||||
|
||||
// Same structural gate the tenant channel list passes (ids, provider types,
|
||||
// SSRF-checked URLs / Telegram chat IDs, header and signing-secret shape). No GA
|
||||
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
|
||||
var channelsError = TenantConfigValidation.ValidateNotificationChannels(config.OpsNotificationChannelsJson);
|
||||
if (channelsError != null)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid ops notification channels: {channelsError}");
|
||||
}
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
|
||||
// Save configuration
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
AutopilotMonitor.Shared.Constants.AuditGlobalTenantId,
|
||||
"UPDATE",
|
||||
"AdminConfiguration",
|
||||
"GlobalConfig",
|
||||
userIdentifier,
|
||||
changes.Count > 0 ? changes : null
|
||||
);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Admin configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "UpdateAdminConfiguration");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class UpdateTenantConfigurationFunction
|
||||
{
|
||||
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
|
||||
public UpdateTenantConfigurationFunction(
|
||||
ILogger<UpdateTenantConfigurationFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
OpsEventService opsEventService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_opsEventService = opsEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
|
||||
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name
|
||||
/// the path — a plain Settings/Admin PUT is <c>portal-put</c>. Allow-listed, never echoed
|
||||
/// from arbitrary input.
|
||||
/// </summary>
|
||||
internal const string CollectLogsIntent = "collect-logs";
|
||||
internal const string CollectLogsSource = "portal-collect-logs";
|
||||
|
||||
internal static string ResolveWriteSource(string? intent) =>
|
||||
string.Equals(intent, CollectLogsIntent, StringComparison.OrdinalIgnoreCase) ? CollectLogsSource : "portal-put";
|
||||
|
||||
[Function("UpdateTenantConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
_logger.LogInformation("UpdateTenantConfiguration: {TenantId} by user {User}", requestCtx.TargetTenantId, userIdentifier);
|
||||
|
||||
// Parse request body
|
||||
// Newtonsoft on purpose: the configuration stack (redaction restore, patch service) is
|
||||
// Newtonsoft-bound — TypedRequestGuardTests baseline.
|
||||
var read = await req.ReadNewtonsoftAsync<TenantConfiguration>(1_048_576);
|
||||
if (read.Error != null) return read.Error;
|
||||
var config = read.Value!;
|
||||
|
||||
// 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();
|
||||
config.CompanyName = string.IsNullOrWhiteSpace(config.CompanyName) ? null : config.CompanyName.Trim();
|
||||
|
||||
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
|
||||
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
|
||||
// races the queued wipe — data written afterwards gets destroyed, and agents
|
||||
// would be un-gated against a tenant whose partitions are being deleted. Refuse
|
||||
// for ALL callers, GA included; once the cascade completes the row is deleted
|
||||
// and re-onboarding starts from a fresh default config anyway.
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
|
||||
{
|
||||
return await req.ConflictAsync("Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
|
||||
}
|
||||
|
||||
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
|
||||
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
|
||||
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
|
||||
// admins are served the FULL config so this is normally a no-op for them.)
|
||||
config.RestoreRedactedSecretsFrom(existingConfig);
|
||||
|
||||
// System-owned fields are never client-writable — for ANY caller, GA included:
|
||||
// identity (DomainName, seeded once from the first login's UPN), onboarding/auth
|
||||
// provenance (OnboardedAt/OnboardedBy/LastAuthClientId*), app-reg homing
|
||||
// (HomedAppClientId — the 2026-07-31 stale-round-trip incident), and plan/trial/
|
||||
// downgrade state (PlanTier/Trial*/ProDowngradedUtc — mutable only via the plan
|
||||
// and trial endpoints; a client-supplied ProDowngradedUtc would refresh the
|
||||
// retention-downgrade grace anchor indefinitely). The set is the PATCH endpoint's
|
||||
// BaseDeniedFields, so the two write paths cannot drift.
|
||||
TenantConfigPatchService.PreserveDeniedFields(config, existingConfig);
|
||||
|
||||
// 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)
|
||||
{
|
||||
return await req.BadRequestAsync(validationError);
|
||||
}
|
||||
|
||||
// Ensure tenant ID matches
|
||||
config.TenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Set the actual user identifier for audit logging
|
||||
config.UpdatedBy = userIdentifier;
|
||||
|
||||
// Protect GA-only fields from non-Global-Admin callers (existingConfig loaded above).
|
||||
if (!requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
if (config.AllowInsecureAgentRequests != existingConfig.AllowInsecureAgentRequests ||
|
||||
config.BootstrapTokenEnabled != existingConfig.BootstrapTokenEnabled ||
|
||||
config.UnrestrictedModeEnabled != existingConfig.UnrestrictedModeEnabled ||
|
||||
config.EntraAppRolesEnabled != existingConfig.EntraAppRolesEnabled ||
|
||||
config.EnableEspContinueAnywayObservation != existingConfig.EnableEspContinueAnywayObservation ||
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.McpDisabled != existingConfig.McpDisabled ||
|
||||
config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
|
||||
userIdentifier, requestCtx.TargetTenantId);
|
||||
}
|
||||
|
||||
config.AllowInsecureAgentRequests = existingConfig.AllowInsecureAgentRequests;
|
||||
config.BootstrapTokenEnabled = existingConfig.BootstrapTokenEnabled;
|
||||
config.UnrestrictedModeEnabled = existingConfig.UnrestrictedModeEnabled;
|
||||
config.EntraAppRolesEnabled = existingConfig.EntraAppRolesEnabled;
|
||||
// Continue-Anyway observation is an operator-set behavioral override —
|
||||
// a tenant admin must not be able to relax their own failure semantics.
|
||||
config.EnableEspContinueAnywayObservation = existingConfig.EnableEspContinueAnywayObservation;
|
||||
config.CustomRateLimitRequestsPerMinute = existingConfig.CustomRateLimitRequestsPerMinute;
|
||||
config.CustomUserRateLimitRequestsPerMinute = existingConfig.CustomUserRateLimitRequestsPerMinute;
|
||||
config.Disabled = existingConfig.Disabled;
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
config.McpDisabled = existingConfig.McpDisabled;
|
||||
config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit;
|
||||
config.McpDisabledReason = existingConfig.McpDisabledReason;
|
||||
}
|
||||
|
||||
// Safety: if GA gate is off, force UnrestrictedMode to false
|
||||
if (!config.UnrestrictedModeEnabled)
|
||||
{
|
||||
config.UnrestrictedMode = false;
|
||||
}
|
||||
|
||||
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
|
||||
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
|
||||
|
||||
// Save configuration (retention cap already enforced by ValidateModel above)
|
||||
var writeSource = ResolveWriteSource(req.Query["intent"]);
|
||||
await _configService.SaveConfigurationAsync(config, writeSource, null);
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"TenantConfiguration",
|
||||
requestCtx.TargetTenantId,
|
||||
userIdentifier,
|
||||
changes.Count > 0 ? changes : null
|
||||
);
|
||||
|
||||
// Operator visibility for the Collect Logs capability flip (the quick-config
|
||||
// dialog writes through this PUT). OpsEventService never throws.
|
||||
var diagChange = DiagnosticsUploadConfigChange.Detect(existingConfig, config);
|
||||
if (diagChange != null)
|
||||
{
|
||||
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
requestCtx.TargetTenantId, config.DomainName, diagChange, userIdentifier ?? "unknown", writeSource);
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "UpdateTenantConfiguration");
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
=> TenantConfigValidation.ValidateContactEmail(email);
|
||||
|
||||
internal static string? ValidateNotificationChannels(string? json)
|
||||
=> TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
|
||||
internal static string? ValidateWebhookCustomHeaders(string? json)
|
||||
=> TenantConfigValidation.ValidateWebhookCustomHeaders(json);
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -255,9 +255,16 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
});
|
||||
}
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: same observation as the session-scoped writes, so
|
||||
// stage-2 enforcement covers this endpoint with the same data. Never rejects here.
|
||||
await ObserveSessionOwnerAsync(req, requestBody.TenantId, requestBody.SessionId, validation);
|
||||
// SESSION-OWNER-BINDING: the SAS is bound to the session, so a caller that does not own
|
||||
// it gets no upload slot in it.
|
||||
if (!await IsSessionOwnerAllowedAsync(req, requestBody.TenantId, requestBody.SessionId, validation))
|
||||
{
|
||||
return await req.JsonAsync(HttpStatusCode.Forbidden, new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Session is bound to another device identity"
|
||||
});
|
||||
}
|
||||
|
||||
HostedUploadSasResult sasResult;
|
||||
try
|
||||
@@ -307,18 +314,23 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ObserveSessionOwnerAsync(
|
||||
/// <summary>
|
||||
/// False only when the owner binding refuses the caller for the session. A failed row read
|
||||
/// fails open — our defect is not evidence of a foreign device.
|
||||
/// </summary>
|
||||
private async Task<bool> IsSessionOwnerAllowedAsync(
|
||||
HttpRequestData req, string tenantId, string sessionId, SecurityValidationResult validation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId);
|
||||
_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url");
|
||||
return !_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url").Rejected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"GetDiagnosticsUploadUrl: session-owner observation skipped for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -36,6 +37,7 @@ public class AuthFunction
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -52,9 +54,11 @@ public class AuthFunction
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_offboardingRepo = offboardingRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
@@ -362,7 +366,9 @@ public class AuthFunction
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
||||
/// and fires best-effort notifications (Telegram + global notification).
|
||||
/// and fires best-effort notifications (Telegram + global notification). The write lands
|
||||
/// only while the STORED row still has no domain (conditional create or update), and only
|
||||
/// the login that wrote it fires the signup side effects.
|
||||
/// </summary>
|
||||
internal async Task HandleNewTenantDomainAsync(
|
||||
TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null)
|
||||
@@ -382,45 +388,85 @@ public class AuthFunction
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
tenantConfig.DomainName = domain;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
// OnboardedBy is immutable once set: this is the only place that writes it, gated
|
||||
// on the same "DomainName is empty" condition (first-ever user login for the tenant).
|
||||
// Downstream auto-promote (PreviewWhitelistFunction) reads OnboardedBy so background
|
||||
// sync jobs that mutate UpdatedBy cannot leak sentinel strings into TenantAdmins.
|
||||
if (string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy))
|
||||
tenantConfig.OnboardedBy = upn;
|
||||
// The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force,
|
||||
// once, in this same first write — later logins never touch it.
|
||||
if (string.IsNullOrWhiteSpace(tenantConfig.DpaVersion))
|
||||
tenantConfig.DpaVersion = Constants.CurrentDpaVersion;
|
||||
// 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;
|
||||
}
|
||||
// A returning tenant: the offboarding deleted its configuration, so the pointer is the only
|
||||
// record of a consumed self-service trial. Carried over in this same write as OnboardedBy, so
|
||||
// no onboarded row (and no auto-promoted admin) ever exists without it (D-288). Unreadable ⇒
|
||||
// no write at all; the self-gate above retries on the next login.
|
||||
OffboardingByTenantPointer? offboardingPointer;
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "first-login domain seed");
|
||||
(offboardingPointer, _) = await _offboardingRepo.TryGetByTenantPointerAsync(tenantId.ToLowerInvariant());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort side-effect: SaveConfigurationAsync now throws on a failed persist
|
||||
// (fail-loud for config endpoints), but a storage blip must not 500 the login.
|
||||
// The handler self-gates on DomainName being empty, so the next login retries —
|
||||
// skip the notifications too so they only fire once the write actually stuck.
|
||||
_logger.LogWarning(ex, "First-login offboarding lookup failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
var seededAt = DateTime.UtcNow;
|
||||
void Seed(TenantConfiguration row)
|
||||
{
|
||||
if (offboardingPointer is { TrialConsumed: true })
|
||||
row.TrialConsumed = true;
|
||||
row.DomainName = domain;
|
||||
row.UpdatedBy = upn;
|
||||
// OnboardedBy is immutable once set: this is the only place that writes it, and the write
|
||||
// below lands only while the STORED row has no domain yet (first-ever login for the tenant),
|
||||
// so two concurrent first logins cannot both write it. Downstream auto-promote reads
|
||||
// OnboardedBy so background sync jobs that mutate UpdatedBy cannot leak sentinel strings
|
||||
// into TenantAdmins.
|
||||
if (string.IsNullOrWhiteSpace(row.OnboardedBy))
|
||||
row.OnboardedBy = upn;
|
||||
// The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force,
|
||||
// once, in this same first write — later logins never touch it.
|
||||
if (string.IsNullOrWhiteSpace(row.DpaVersion))
|
||||
row.DpaVersion = Constants.CurrentDpaVersion;
|
||||
// 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.
|
||||
if (onboardingClientId != null)
|
||||
{
|
||||
if (_appRegistry.IsPrimary(onboardingClientId))
|
||||
row.HomedAppClientId = onboardingClientId;
|
||||
row.LastAuthClientId = onboardingClientId;
|
||||
row.LastAuthClientIdSince = seededAt;
|
||||
}
|
||||
}
|
||||
|
||||
TenantConfigUpdate update;
|
||||
try
|
||||
{
|
||||
update = await _tenantConfigService.CreateOrUpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(row.DomainName))
|
||||
return false; // another login seeded it since this one read the configuration
|
||||
Seed(row);
|
||||
return true;
|
||||
}, "auth", "first-login domain seed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A storage blip must not 500 the login. The handler self-gates on DomainName being
|
||||
// empty, so the next login retries — skip the notifications too so they only fire once
|
||||
// the write actually stuck.
|
||||
_logger.LogWarning(ex, "First-login domain write failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
// Declined: a concurrent login won the seed and fires the signup side effects itself.
|
||||
// Conflict: the next login retries. Tombstone: the tenant is being offboarded.
|
||||
_logger.LogInformation("First-login domain seed for tenant {TenantId} not written ({Status})", tenantId, update.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Seeded domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
// This login's view follows what was just stored.
|
||||
Seed(tenantConfig);
|
||||
|
||||
// Fire-and-forget: Telegram
|
||||
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
@@ -464,26 +510,29 @@ public class AuthFunction
|
||||
var clientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
if (clientId == null || string.IsNullOrEmpty(tenantConfig.DomainName))
|
||||
return;
|
||||
// Cheap pre-filter on the cached view; the write decides again on the fresh row.
|
||||
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");
|
||||
string? previous = null;
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (string.Equals(row.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
previous = row.LastAuthClientId;
|
||||
row.LastAuthClientId = clientId;
|
||||
row.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
return true;
|
||||
}, "auth", "auth client-id tracking");
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
|
||||
tenantId, clientId, previous ?? "(none recorded)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -494,7 +543,9 @@ public class AuthFunction
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant's suspension has expired (Disabled=true but DisabledUntil is past),
|
||||
/// clears the disabled state and persists the change.
|
||||
/// clears the disabled state and persists the change. The cached view only triggers the check:
|
||||
/// the decision runs on the FRESH row, so a suspension a Global Admin renewed meanwhile (or the
|
||||
/// offboarding tombstone) is never lifted, and this login's view takes over the stored state.
|
||||
/// MUST run before BuildAuthResult because it mutates tenantConfig.Disabled.
|
||||
/// </summary>
|
||||
internal async Task HandleAutoReEnableAsync(
|
||||
@@ -503,24 +554,44 @@ public class AuthFunction
|
||||
if (!tenantConfig.Disabled || tenantConfig.IsCurrentlyDisabled())
|
||||
return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
|
||||
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
|
||||
|
||||
tenantConfig.Disabled = false;
|
||||
tenantConfig.DisabledReason = null;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
tenantConfig.UpdatedBy = "System (auto-re-enable)";
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "auto-re-enable after suspension expiry");
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (!row.Disabled || row.IsCurrentlyDisabled())
|
||||
return false;
|
||||
row.Disabled = false;
|
||||
row.DisabledReason = null;
|
||||
row.DisabledUntil = null;
|
||||
row.UpdatedBy = "System (auto-re-enable)";
|
||||
return true;
|
||||
}, "auth", "auto-re-enable after suspension expiry");
|
||||
|
||||
if (update.Config != null)
|
||||
{
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
|
||||
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
|
||||
}
|
||||
tenantConfig.Disabled = update.Config.Disabled;
|
||||
tenantConfig.DisabledReason = update.Config.DisabledReason;
|
||||
tenantConfig.DisabledUntil = update.Config.DisabledUntil;
|
||||
return;
|
||||
}
|
||||
// NotFound / Conflict: storage could not settle it — fall through.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: the in-memory flip above already lets THIS login through; a failed
|
||||
// persist just means the next login re-runs the auto-re-enable. Must not 500 auth/me.
|
||||
_logger.LogWarning(ex, "Auto-re-enable persist failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
}
|
||||
|
||||
// Best-effort: the expired suspension still lets THIS login through; the next login
|
||||
// re-runs the auto-re-enable. Must not 500 auth/me.
|
||||
tenantConfig.Disabled = false;
|
||||
tenantConfig.DisabledReason = null;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -265,12 +266,18 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// EventIngestProcessor, saving one Sessions point-read per batch.
|
||||
var preFetchedStatus = TryReadSessionStatus(guardSessionRow);
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: is the device behind this certificate/token the
|
||||
// device this session belongs to? Same guard row, zero extra reads. Stage 1 records
|
||||
// the outcome (request-row dimension + Warning + throttled ops event) and stamps
|
||||
// legacy claims / rebinds; it never refuses the batch. Covers the Signal-only path
|
||||
// too — that is where pending ServerActions get fetched-and-cleared.
|
||||
// SESSION-OWNER-BINDING: is the device behind this certificate/token the device this
|
||||
// session belongs to? Same guard row, zero extra reads. A foreign identity gets 403
|
||||
// before anything is stored — covering the Signal-only path too, which is where
|
||||
// pending ServerActions get fetched-and-cleared. An honest agent never gets here:
|
||||
// it picks its certificate once per process and registration (which rotates on
|
||||
// mismatch) precedes its first batch. Tolerated outcomes stamp legacy claims / rebinds.
|
||||
var ownerDecision = _ownerBinding.Observe(req, bodyTenantId, sessionId, guardSessionRow, validation, "agent/telemetry");
|
||||
if (ownerDecision.Rejected)
|
||||
{
|
||||
return AsOutput(await req.ErrorAsync(HttpStatusCode.Forbidden, Constants.ApiErrorCodes.SessionOwnerMismatch,
|
||||
"Session is bound to another device identity; the batch was not stored."));
|
||||
}
|
||||
if (guardSessionRow != null)
|
||||
await _ownerBinding.StampAsync(bodyTenantId, sessionId, ownerDecision);
|
||||
|
||||
|
||||
+160
-76
@@ -1,13 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -41,6 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
private readonly Services.Deletion.ISessionDeletionInventoryReader _sessionRowReader;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
private readonly SessionOwnerBindingObserver _ownerBinding;
|
||||
|
||||
public ReportAgentErrorFunction(
|
||||
@@ -58,6 +62,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
ISessionRepository sessionRepo,
|
||||
OpsEventService opsEventService,
|
||||
Services.Deletion.ISessionDeletionInventoryReader sessionRowReader,
|
||||
SessionDeletionGuard deletionGuard,
|
||||
SessionOwnerBindingObserver ownerBinding)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -74,6 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_sessionRepo = sessionRepo;
|
||||
_opsEventService = opsEventService;
|
||||
_sessionRowReader = sessionRowReader;
|
||||
_deletionGuard = deletionGuard;
|
||||
_ownerBinding = ownerBinding;
|
||||
}
|
||||
|
||||
@@ -184,14 +190,20 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
["AgentVersion"] = report.AgentVersion ?? string.Empty,
|
||||
["Message"] = report.Message ?? string.Empty,
|
||||
["AgentTimestamp"] = report.Timestamp.ToString("O"),
|
||||
["SessionAgeHours"] = report.SessionAgeHours?.ToString("0.0", CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
});
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: the emergency-break path below writes timeline events
|
||||
// into whatever session the report names. Observe the binding here as well (one
|
||||
// point-read — this function has no guard row in hand); no stamping from this path.
|
||||
await ObserveSessionOwnerAsync(req, tenantId, report.SessionId, validation);
|
||||
// One Sessions-row point-read serves the owner binding and the emergency-break verdict.
|
||||
var sessionRead = await ReadSessionRowAsync(tenantId, report.SessionId);
|
||||
|
||||
await MaterializeEmergencyBreakArtifactsAsync(report, tenantId, _sessionRepo, _opsEventService, _logger);
|
||||
// SESSION-OWNER-BINDING: the emergency-break path below writes timeline events into
|
||||
// whatever session the report names (no stamping from this path). A report naming a
|
||||
// session bound to another device identity keeps its device-scoped diagnostics above
|
||||
// and the binary-integrity check below, but must not write into that session. Still
|
||||
// 200: the channel is never retried, and the agent ignores its status.
|
||||
if (IsSessionOwnerAllowed(req, tenantId, report.SessionId, sessionRead, validation))
|
||||
await MaterializeEmergencyBreakArtifactsAsync(
|
||||
report, tenantId, sessionRead, _sessionRepo, _deletionGuard, _opsEventService, _logger);
|
||||
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
await MaterializeIntegrityMismatchAsync(
|
||||
@@ -201,18 +213,48 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task ObserveSessionOwnerAsync(HttpRequestData req, string tenantId, string? sessionId, SecurityValidationResult validation)
|
||||
/// <summary>
|
||||
/// Outcome of the one Sessions-row point-read: <see cref="Row"/> is null when the row does
|
||||
/// not exist, <see cref="Failed"/> marks a read that threw (existence unknown).
|
||||
/// </summary>
|
||||
internal readonly record struct SessionRowRead(TableEntity? Row, bool Failed);
|
||||
|
||||
/// <summary>
|
||||
/// Null when the ids are not GUIDs: no row can exist for them, so neither the owner binding
|
||||
/// nor the emergency break has anything to act on.
|
||||
/// </summary>
|
||||
private async Task<SessionRowRead?> ReadSessionRowAsync(string tenantId, string? sessionId)
|
||||
{
|
||||
if (!SecurityValidator.IsValidGuid(sessionId) || !SecurityValidator.IsValidGuid(tenantId))
|
||||
return;
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId!);
|
||||
_ownerBinding.Observe(req, tenantId, sessionId!, row, validation, "agent/error");
|
||||
return new SessionRowRead(await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId!), Failed: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReportAgentError: session row read failed for session {SessionId}", sessionId);
|
||||
return new SessionRowRead(null, Failed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// False only when the owner binding refuses the caller for the named session. Ids that are
|
||||
/// not GUIDs have no row to compare against; a failed row read or evaluation fails open (our
|
||||
/// defect is not evidence of a foreign device).
|
||||
/// </summary>
|
||||
private bool IsSessionOwnerAllowed(HttpRequestData req, string tenantId, string? sessionId, SessionRowRead? sessionRead, SecurityValidationResult validation)
|
||||
{
|
||||
if (sessionRead is not { Failed: false } read)
|
||||
return true;
|
||||
try
|
||||
{
|
||||
return !_ownerBinding.Observe(req, tenantId, sessionId!, read.Row, validation, "agent/error").Rejected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReportAgentError: session-owner observation skipped for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,74 +338,65 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
/// Materializes the agent's silent 48h emergency break as (1) a timeline event so it shows
|
||||
/// in the session and the timeout classifier can see it, (2) the cross-session EventType
|
||||
/// index row, and (3) an <c>AgentEmergencyBreak</c> ops event for operator visibility
|
||||
/// (tasks/enrollment-status-reclassification.md). Static seam with explicit dependencies so
|
||||
/// tests can pin the artifact set without booting the Functions HTTP stack.
|
||||
/// Best-effort — a failure here must never turn the always-200 emergency channel into a
|
||||
/// retry loop.
|
||||
/// (tasks/enrollment-status-reclassification.md). (1) and (2) only when the session has a
|
||||
/// writable row (<see cref="ClassifyEmergencyBreak"/>); the ops event always, because the
|
||||
/// feed counts breaks. Static seam with explicit dependencies so tests can pin the artifact
|
||||
/// set without booting the Functions HTTP stack. Best-effort — a failure here must never
|
||||
/// turn the always-200 emergency channel into a retry loop.
|
||||
/// </summary>
|
||||
internal static async Task MaterializeEmergencyBreakArtifactsAsync(
|
||||
AgentErrorReport report,
|
||||
string tenantId,
|
||||
SessionRowRead? sessionRead,
|
||||
ISessionRepository sessionRepo,
|
||||
SessionDeletionGuard deletionGuard,
|
||||
OpsEventService opsEventService,
|
||||
ILogger logger)
|
||||
{
|
||||
if (report.ErrorType != AgentErrorType.SessionAgeEmergencyBreak
|
||||
|| string.IsNullOrEmpty(report.SessionId))
|
||||
// No read means the ids are not GUIDs — no session can exist to break.
|
||||
if (report.ErrorType != AgentErrorType.SessionAgeEmergencyBreak || sessionRead is not { } read)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await sessionRepo.GetSessionEventsAsync(tenantId, report.SessionId, maxResults: 1000);
|
||||
// Idempotency: the agent's emergency channel can send up to a few reports per session;
|
||||
// only ever materialize one timeline event (and one ops event).
|
||||
var alreadyMaterialized = existing.Any(e =>
|
||||
string.Equals(e.EventType, Constants.EventTypes.AgentEmergencyBreak, StringComparison.OrdinalIgnoreCase));
|
||||
if (alreadyMaterialized)
|
||||
var verdict = ClassifyEmergencyBreak(read.Row, read.Failed, IsDeletionLocked(read.Row, deletionGuard));
|
||||
var message = BreakMessage(report);
|
||||
|
||||
if (verdict.Materialize)
|
||||
{
|
||||
return;
|
||||
var existing = await sessionRepo.GetSessionEventsAsync(tenantId, report.SessionId, maxResults: 1000);
|
||||
// Idempotency: the agent's emergency channel can send up to a few reports per
|
||||
// session; only ever materialize one timeline event (and one ops event).
|
||||
var alreadyMaterialized = existing.Any(e =>
|
||||
string.Equals(e.EventType, Constants.EventTypes.AgentEmergencyBreak, StringComparison.OrdinalIgnoreCase));
|
||||
if (alreadyMaterialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var evt = BuildAgentEmergencyBreakEvent(report, tenantId, existing, DateTime.UtcNow);
|
||||
await sessionRepo.StoreEventsBatchAsync(new List<EnrollmentEvent> { evt });
|
||||
|
||||
// StoreEventsBatchAsync writes the Events partition only — the cross-session
|
||||
// EventType index is normally written by EventIngestProcessor, which this
|
||||
// backend-materialized event never passes through. Without the upsert the event
|
||||
// exists on the session timeline but is invisible to every search-by-eventType
|
||||
// surface (portal cross-session search, MCP search_sessions_by_event /
|
||||
// query_raw_events) — found the hard way in the 2026-07-22 incident analysis.
|
||||
await sessionRepo.UpsertEventTypeIndexBatchAsync(
|
||||
tenantId, report.SessionId, new List<EnrollmentEvent> { evt });
|
||||
}
|
||||
|
||||
var evt = BuildAgentEmergencyBreakEvent(report, tenantId, existing, DateTime.UtcNow);
|
||||
await sessionRepo.StoreEventsBatchAsync(new List<EnrollmentEvent> { evt });
|
||||
|
||||
// StoreEventsBatchAsync writes the Events partition only — the cross-session
|
||||
// EventType index is normally written by EventIngestProcessor, which this
|
||||
// backend-materialized event never passes through. Without the upsert the event
|
||||
// exists on the session timeline but is invisible to every search-by-eventType
|
||||
// surface (portal cross-session search, MCP search_sessions_by_event /
|
||||
// query_raw_events) — found the hard way in the 2026-07-22 incident analysis.
|
||||
await sessionRepo.UpsertEventTypeIndexBatchAsync(
|
||||
tenantId, report.SessionId, new List<EnrollmentEvent> { evt });
|
||||
|
||||
// Operator visibility: an emergency break means an agent silently gave up at its
|
||||
// absolute age cap — exactly the "are we losing agents?" signal. Emitted only on
|
||||
// first materialization so repeat reports for the same session cannot flood the
|
||||
// ops feed. OpsEventService never throws.
|
||||
//
|
||||
// Severity follows what the platform already knows about the session (ops audit
|
||||
// 2026-09-03): the break is wall-clock, so a device that was powered off for weeks
|
||||
// fires it on its first boot — long after the sweep reconciled the session. That
|
||||
// break is a late cleanup, not news; only a break on a still-open session is the
|
||||
// decision itself (classifier rule 3). The row read is best-effort: unknown status
|
||||
// keeps the historical Warning.
|
||||
SessionStatus? statusAtBreak = null;
|
||||
try
|
||||
{
|
||||
statusAtBreak = (await sessionRepo.GetSessionAsync(tenantId, report.SessionId))?.Status;
|
||||
}
|
||||
catch (Exception readEx)
|
||||
{
|
||||
logger.LogDebug(readEx,
|
||||
"ReportAgentError: session status read failed for emergency-break severity on {SessionId}", report.SessionId);
|
||||
}
|
||||
|
||||
var verdict = ClassifyEmergencyBreak(statusAtBreak);
|
||||
// absolute age cap — exactly the "are we losing agents?" signal. For a session with
|
||||
// a row it is emitted only on first materialization, so repeat reports cannot flood
|
||||
// the feed. OpsEventService never throws.
|
||||
await opsEventService.RecordAgentEmergencyBreakAsync(
|
||||
tenantId, report.SessionId, report.AgentVersion, evt.Message,
|
||||
verdict.Severity, statusAtBreak, verdict.LateCleanup);
|
||||
tenantId, report.SessionId, report.AgentVersion, message,
|
||||
verdict.Severity, verdict.SessionStatusAtBreak, verdict.LateCleanup, verdict.Context,
|
||||
report.SessionAgeHours);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -373,17 +406,66 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event verdict for an emergency break, keyed on the session status the platform holds
|
||||
/// when the report arrives. Terminal (Succeeded / Failed / Incomplete) = the sweep already
|
||||
/// decided the enrollment and the agent merely cleaned up late (device powered on after a
|
||||
/// shelf period) — Info, <c>LateCleanup=true</c>. Open or unknown = the break is the first
|
||||
/// and only signal that nothing more will arrive — Warning, the historical semantics.
|
||||
/// The cascade-delete writer-block invariant, checked at its chokepoint on the row already in
|
||||
/// hand (no second read). A missing row needs no tombstone check: nothing is written for it.
|
||||
/// </summary>
|
||||
private static bool IsDeletionLocked(TableEntity? sessionRow, SessionDeletionGuard deletionGuard)
|
||||
{
|
||||
try
|
||||
{
|
||||
deletionGuard.ThrowIfLocked(sessionRow, "V2.ReportAgentError.EmergencyBreak");
|
||||
return false;
|
||||
}
|
||||
catch (SessionDeletionLockedException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the ops event says and whether the break is written into the session.</summary>
|
||||
internal readonly record struct EmergencyBreakVerdict(
|
||||
string Severity, bool LateCleanup, bool Materialize, string SessionStatusAtBreak, string Context);
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event verdict for an emergency break, keyed on what the platform holds for the session
|
||||
/// when the report arrives. The break is wall-clock and checked at agent start, so it usually
|
||||
/// arrives long after the fact:
|
||||
/// <list type="bullet">
|
||||
/// <item>Terminal (Succeeded / Failed / Incomplete): the sweep already decided the enrollment
|
||||
/// and the agent merely cleaned up late after a shelf period — Info, <c>LateCleanup=true</c>
|
||||
/// (ops audit 2026-09-03).</item>
|
||||
/// <item>No row: in practice a session that never registered (the agent went dark before
|
||||
/// register-session) — nothing to close and nobody who could open it. Info, and no
|
||||
/// timeline event or index row: the orphan sweep would delete the event a day later with
|
||||
/// a Warning of its own, and the index row would point at a session that does not
|
||||
/// exist.</item>
|
||||
/// <item>Row under cascade delete: nothing may be written past the lock — Info.</item>
|
||||
/// <item>Open or unparseable status, or a failed read: the break is the first and only
|
||||
/// signal that nothing more will arrive (classifier rule 3) — Warning, and a failed read
|
||||
/// keeps the historical write.</item>
|
||||
/// </list>
|
||||
/// Pure so the rule is unit-testable.
|
||||
/// </summary>
|
||||
internal static (string Severity, bool LateCleanup) ClassifyEmergencyBreak(SessionStatus? statusAtBreak)
|
||||
=> statusAtBreak.HasValue && Helpers.DeviceJourneyCalculator.IsTerminal(statusAtBreak.Value)
|
||||
? (OpsEventSeverity.Info, true)
|
||||
: (OpsEventSeverity.Warning, false);
|
||||
internal static EmergencyBreakVerdict ClassifyEmergencyBreak(TableEntity? sessionRow, bool rowReadFailed, bool deletionLocked)
|
||||
{
|
||||
if (rowReadFailed)
|
||||
return new(OpsEventSeverity.Warning, false, true, "unknown", "session row unreadable when the break arrived");
|
||||
if (sessionRow == null)
|
||||
return new(OpsEventSeverity.Info, false, false, "missing", "no session row (never registered, or deleted long ago) — nothing to close");
|
||||
if (deletionLocked)
|
||||
return new(OpsEventSeverity.Info, false, false, "deleting", "session is being deleted — nothing written");
|
||||
|
||||
var status = IngestTelemetryFunction.TryReadSessionStatus(sessionRow);
|
||||
var statusText = status?.ToString() ?? "unknown";
|
||||
return status.HasValue && Helpers.DeviceJourneyCalculator.IsTerminal(status.Value)
|
||||
? new(OpsEventSeverity.Info, true, true, statusText, $"late cleanup — session already {statusText} when the break arrived")
|
||||
: new(OpsEventSeverity.Warning, false, true, statusText, $"session still {statusText} when the break arrived");
|
||||
}
|
||||
|
||||
private static string BreakMessage(AgentErrorReport report)
|
||||
=> string.IsNullOrWhiteSpace(report.Message)
|
||||
? "Agent absolute session-age emergency break fired — agent cleaned up and exited"
|
||||
: report.Message;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the backend-materialized <c>agent_emergency_break</c> timeline event from the agent's
|
||||
@@ -407,6 +489,15 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
? report.Timestamp.ToUniversalTime()
|
||||
: nowUtc;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["source"] = "emergency_channel",
|
||||
["agentVersion"] = report.AgentVersion ?? string.Empty,
|
||||
["reportedAtUtc"] = report.Timestamp.ToString("o"),
|
||||
};
|
||||
if (report.SessionAgeHours.HasValue)
|
||||
data["sessionAgeHours"] = report.SessionAgeHours.Value;
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -417,15 +508,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Timestamp = breakAt,
|
||||
Sequence = maxSequence + 1,
|
||||
Message = string.IsNullOrWhiteSpace(report.Message)
|
||||
? "Agent absolute session-age emergency break fired — agent cleaned up and exited"
|
||||
: report.Message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["source"] = "emergency_channel",
|
||||
["agentVersion"] = report.AgentVersion ?? string.Empty,
|
||||
["reportedAtUtc"] = report.Timestamp.ToString("o"),
|
||||
},
|
||||
Message = BreakMessage(report),
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -301,11 +301,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return groupBy.ToLower() switch
|
||||
{
|
||||
"country" => session.GeoCountry,
|
||||
"region" => $"{session.GeoRegion}, {session.GeoCountry}",
|
||||
_ => !string.IsNullOrEmpty(session.GeoCity)
|
||||
? $"{session.GeoCity}, {session.GeoRegion}, {session.GeoCountry}"
|
||||
: $"{session.GeoRegion}, {session.GeoCountry}"
|
||||
"region" => JoinLocationParts(session.GeoRegion, session.GeoCountry),
|
||||
_ => JoinLocationParts(session.GeoCity, session.GeoRegion, session.GeoCountry)
|
||||
};
|
||||
}
|
||||
|
||||
// Providers can omit region/city (the agent's last-resort provider returns the country
|
||||
// only), so empty parts are skipped instead of producing keys like ", DE".
|
||||
private static string JoinLocationParts(params string[] parts) =>
|
||||
string.Join(", ", parts.Where(p => !string.IsNullOrEmpty(p)));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -189,14 +189,15 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
bool isFreshRegistration = preExistingSession == null;
|
||||
bool isWhiteGloveResume = preExistingSession?.Status == SessionStatus.Pending;
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: compare the caller's validated identity against the
|
||||
// owner already on the row (reusing the guard's read). Stage 1 never refuses — the
|
||||
// decision only records the outcome and yields the owner to stamp (fresh bind,
|
||||
// legacy claim, cert rotation, bootstrap→cert handoff). Enforcement (stage 2) will turn
|
||||
// a would-reject here into 403 + AgentErrorCodes.SessionOwnerMismatch, on which the
|
||||
// agent rotates its session id.
|
||||
// SESSION-OWNER-BINDING: compare the caller's validated identity against the owner
|
||||
// already on the row (reusing the guard's read). A foreign identity is refused with
|
||||
// 403 + AgentErrorCodes.SessionOwnerMismatch BEFORE anything is written — the agent
|
||||
// rotates its session id on that code and registers afresh. Tolerated outcomes yield
|
||||
// the owner to stamp (fresh bind, legacy claim, cert rotation, bootstrap→cert handoff).
|
||||
var ownerDecision = _ownerBinding.Observe(
|
||||
req, registration.TenantId, registration.SessionId, guardSessionRow, validation, "agent/register-session");
|
||||
if (ownerDecision.Rejected)
|
||||
return new RegisterSessionOutput { HttpResponse = await WriteSessionOwnerMismatchAsync(req, registration.SessionId) };
|
||||
|
||||
// Store session in Azure Table Storage. The sessionId → tenantId lookup claim inside is
|
||||
// first-writer-wins: a sessionId already owned by another tenant is refused with 409 so
|
||||
@@ -470,6 +471,21 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 403 for a session bound to another device identity (SESSION-OWNER-BINDING). Carries
|
||||
/// <see cref="Constants.AgentErrorCodes.SessionOwnerMismatch"/>, on which the agent rotates its
|
||||
/// SessionId and registers afresh instead of counting an auth failure.
|
||||
/// </summary>
|
||||
private static Task<HttpResponseData> WriteSessionOwnerMismatchAsync(HttpRequestData req, string sessionId)
|
||||
=> req.JsonAsync(HttpStatusCode.Forbidden, new RegisterSessionResponse
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Success = false,
|
||||
Message = "Session is bound to another device identity; register with a new session id.",
|
||||
RegisteredAt = DateTime.UtcNow,
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||||
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
|
||||
{
|
||||
|
||||
+4
-6
@@ -49,12 +49,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
|
||||
if (string.IsNullOrEmpty(msrc.Error))
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
{
|
||||
config.MsrcLastSyncUtc = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
}
|
||||
var syncedAt = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.MsrcLastSyncUtc = syncedAt; return null; },
|
||||
"System (MSRC sync)", "vuln-sync");
|
||||
}
|
||||
|
||||
var durationMs = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
|
||||
|
||||
+4
-6
@@ -97,12 +97,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
// Update sync timestamp
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
{
|
||||
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
}
|
||||
var syncedAt = DateTime.UtcNow.ToString("o");
|
||||
await _adminConfigService.UpdateAsync(
|
||||
c => { c.VulnerabilityDataLastSyncUtc = syncedAt; return null; },
|
||||
"System (vulnerability sync)", "vuln-sync");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TriggerVulnerabilityDataSyncResponse
|
||||
|
||||
+6
-14
@@ -117,22 +117,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// "Last KEV/MSRC Sync" accurately. (NVD/EPSS stamps are written by the service.)
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
if (config != null)
|
||||
if (kevSyncedAt != null || msrcSyncedAt != null)
|
||||
{
|
||||
bool dirty = false;
|
||||
if (kevSyncedAt != null)
|
||||
await _adminConfigService.UpdateAsync(c =>
|
||||
{
|
||||
config.VulnerabilityDataLastSyncUtc = kevSyncedAt;
|
||||
dirty = true;
|
||||
}
|
||||
if (msrcSyncedAt != null)
|
||||
{
|
||||
config.MsrcLastSyncUtc = msrcSyncedAt;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty)
|
||||
await _adminConfigService.SaveConfigurationAsync(config);
|
||||
if (kevSyncedAt != null) c.VulnerabilityDataLastSyncUtc = kevSyncedAt;
|
||||
if (msrcSyncedAt != null) c.MsrcLastSyncUtc = msrcSyncedAt;
|
||||
return null;
|
||||
}, "System (vulnerability sync)", "vuln-sync");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class AppVersionDurationRegressionFinding
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
/// <summary>Install channel (<see cref="AppInstallSources"/>); part of the app identity.</summary>
|
||||
public string Source { get; init; } = AppInstallSources.Ime;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string PreviousVersion { get; init; } = string.Empty;
|
||||
|
||||
@@ -23,7 +25,7 @@ public sealed class AppVersionDurationRegressionFinding
|
||||
/// <summary>
|
||||
/// Deterministic, I/O-free core of the app-version duration regression radar. Detects
|
||||
/// "version X made this app's installs much slower" from the per-(session, app) install
|
||||
/// summaries: per (tenant, app), the newest version's MEDIAN measured install duration is
|
||||
/// summaries: per (tenant, channel, app), the newest version's MEDIAN measured install duration is
|
||||
/// compared against the previous version's median. Medians (nearest-rank, shared with the
|
||||
/// apps dashboard via <see cref="AppsAnalyticsHelper.Percentile"/>) rather than means —
|
||||
/// a single back-stamped straggler must not fire a fleet alert. A regression fires only
|
||||
@@ -90,7 +92,8 @@ public static class AppVersionRegressionRadar
|
||||
findings.Add(new AppVersionDurationRegressionFinding
|
||||
{
|
||||
TenantId = app.Value[0].TenantId,
|
||||
AppName = app.Key,
|
||||
AppName = app.Key.AppName,
|
||||
Source = app.Key.Source,
|
||||
CurrentVersion = current.Version,
|
||||
PreviousVersion = previous.Version,
|
||||
CurrentMedianSeconds = current.MedianSeconds,
|
||||
@@ -103,6 +106,7 @@ public static class AppVersionRegressionRadar
|
||||
return findings
|
||||
.OrderByDescending(f => f.Lift)
|
||||
.ThenBy(f => f.AppName, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -116,7 +120,7 @@ public static class AppVersionRegressionRadar
|
||||
/// </summary>
|
||||
public static bool ShouldReArm(IReadOnlyList<AppInstallSummary> tenantSummaries, AppVersionRegressionAlert alert)
|
||||
{
|
||||
var stats = ComputeVersionStatsForApp(tenantSummaries, alert.AppName);
|
||||
var stats = ComputeVersionStatsForApp(tenantSummaries, AppInstallSources.Normalize(alert.Source), alert.AppName);
|
||||
var current = stats.FirstOrDefault(s => string.Equals(s.Version, alert.CurrentVersion, StringComparison.Ordinal));
|
||||
if (current == null || current.MeasuredCount < MinMeasuredInstalls) return true;
|
||||
|
||||
@@ -126,26 +130,23 @@ public static class AppVersionRegressionRadar
|
||||
return current.MedianSeconds < ReArmLiftFactor * previous.MedianSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Per-version stats for one app (measured rows only), or empty when the app has none.</summary>
|
||||
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> tenantSummaries, string appName)
|
||||
/// <summary>Per-version stats for one app of one channel (measured rows only), or empty when the app has none.</summary>
|
||||
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> tenantSummaries, string source, string appName)
|
||||
{
|
||||
foreach (var app in MeasuredAppGroups(tenantSummaries))
|
||||
{
|
||||
if (string.Equals(app.Key, appName, StringComparison.OrdinalIgnoreCase))
|
||||
return ComputeVersionStats(app.Value);
|
||||
}
|
||||
return new List<VersionStats>();
|
||||
return MeasuredAppGroups(tenantSummaries).TryGetValue((source, appName), out var rows)
|
||||
? ComputeVersionStats(rows)
|
||||
: new List<VersionStats>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups the MEASURED rows by app name: succeeded, non-skip, plausible duration,
|
||||
/// Groups the MEASURED rows by (channel, app name): succeeded, non-skip, plausible duration,
|
||||
/// no AppId collision (a second app's outcomes must not fire this app's alert),
|
||||
/// non-empty version. Rate/failure signals are deliberately out of scope — this
|
||||
/// radar alerts on duration only.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
|
||||
internal static Dictionary<(string Source, string AppName), List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
|
||||
{
|
||||
var groups = new Dictionary<string, List<AppInstallSummary>>(StringComparer.OrdinalIgnoreCase);
|
||||
var groups = new Dictionary<(string Source, string AppName), List<AppInstallSummary>>(AppKeyComparer.Instance);
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary.AppName)) continue;
|
||||
@@ -158,16 +159,30 @@ public static class AppVersionRegressionRadar
|
||||
// enter the duration population, on both sides of every comparison.
|
||||
if (!summary.LastAttemptStartedAt.HasValue) continue;
|
||||
|
||||
if (!groups.TryGetValue(summary.AppName, out var list))
|
||||
var key = (AppInstallSources.Normalize(summary.Source), summary.AppName);
|
||||
if (!groups.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<AppInstallSummary>();
|
||||
groups[summary.AppName] = list;
|
||||
groups[key] = list;
|
||||
}
|
||||
list.Add(summary);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>(channel, app) key: channel ordinal, app name case-insensitive like every per-app lookup.</summary>
|
||||
private sealed class AppKeyComparer : IEqualityComparer<(string Source, string AppName)>
|
||||
{
|
||||
public static readonly AppKeyComparer Instance = new();
|
||||
|
||||
public bool Equals((string Source, string AppName) x, (string Source, string AppName) y) =>
|
||||
string.Equals(x.Source, y.Source, StringComparison.Ordinal) &&
|
||||
string.Equals(x.AppName, y.AppName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int GetHashCode((string Source, string AppName) key) =>
|
||||
HashCode.Combine(StringComparer.Ordinal.GetHashCode(key.Source), StringComparer.OrdinalIgnoreCase.GetHashCode(key.AppName));
|
||||
}
|
||||
|
||||
/// <summary>Per-version first/last-seen and nearest-rank median over one app's measured rows.</summary>
|
||||
internal static List<VersionStats> ComputeVersionStats(IReadOnlyList<AppInstallSummary> measuredAppRows)
|
||||
{
|
||||
|
||||
@@ -81,7 +81,8 @@ public static class MetricsMath
|
||||
// keeps every row, since transferred bytes are real regardless of identity mixing.
|
||||
var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision);
|
||||
|
||||
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
||||
// One group per (channel, name) — see AppsAnalyticsHelper.BuildAppsListResponse.
|
||||
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g =>
|
||||
{
|
||||
// PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration:
|
||||
// skipped = no real install attempt (TerminalState Skipped/Postponed)
|
||||
@@ -105,7 +106,8 @@ public static class MetricsMath
|
||||
|
||||
return new AppMetricsAppGroup
|
||||
{
|
||||
AppName = g.Key,
|
||||
AppName = g.Key.AppName,
|
||||
Source = g.Key.Source,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
Skipped = skipped.Count,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
|
||||
@@ -89,7 +90,8 @@ public sealed class ResiliencePolicies
|
||||
private static IAsyncPolicy<HttpResponseMessage> BuildNotification(ILogger logger)
|
||||
=> Policy<HttpResponseMessage>
|
||||
.HandleResult(r => TransientCodes.Contains(r.StatusCode))
|
||||
.Or<HttpRequestException>()
|
||||
// A destination the SSRF gate refused stays refused; a retry would only resolve it again.
|
||||
.Or<HttpRequestException>(ex => ex.InnerException is not SsrfException)
|
||||
.Or<TaskCanceledException>()
|
||||
.WaitAndRetryAsync(
|
||||
retryCount: 2,
|
||||
|
||||
@@ -9,10 +9,9 @@ using AutopilotMonitor.Shared.Models.Config;
|
||||
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.
|
||||
/// Single source for tenant-configuration model validation, used by the transactional
|
||||
/// field-patch flow (<c>TenantConfigPatchService</c>) — the one client write path of the
|
||||
/// tenant configuration since the full-model PUT was removed (D-290).
|
||||
/// </summary>
|
||||
internal static class TenantConfigValidation
|
||||
{
|
||||
|
||||
@@ -184,8 +184,8 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
if (context.Items.TryGetValue(CertTenantBinding.RequestItemKey, out var binding) && binding is string bindingOutcome)
|
||||
requestTelemetry.Properties["CertTenantBinding"] = bindingOutcome;
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW — set by SessionOwnerBindingObserver on register /
|
||||
// telemetry / error-report requests. Same carrier and same reasoning as above.
|
||||
// SESSION-OWNER-BINDING — set by SessionOwnerBindingObserver on register / telemetry /
|
||||
// error-report / upload-url requests. Same carrier and same reasoning as above.
|
||||
if (context.Items.TryGetValue(SessionOwnershipPolicy.RequestItemKey, out var ownerBinding) && ownerBinding is string ownerOutcome)
|
||||
requestTelemetry.Properties["SessionOwnerBinding"] = ownerOutcome;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -470,14 +471,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolutio
|
||||
AutopilotMonitor.Functions.Services.GraphResolution.GraphFeatureDetector>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolution.IScriptDisplayNameResolver,
|
||||
AutopilotMonitor.Functions.Services.GraphResolution.ScriptDisplayNameResolver>();
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
builder.Services.AddWebhookNotificationHttpClient();
|
||||
builder.Services.AddHttpClient<TelegramNotificationService>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// - X.509 chain trust pinned to embedded Intune root certificate(s) via
|
||||
/// X509ChainTrustMode.CustomRootTrust (OS trust store is ignored)
|
||||
/// - No revocation checking (Intune certificates have no CRL/OCSP endpoints)
|
||||
/// - No network I/O during chain building: issuer downloads (AIA) are disabled
|
||||
/// - Prevents self-signed certificate attacks
|
||||
/// - Validates Enhanced Key Usage for Client Authentication
|
||||
/// - In-memory cache for validated certificates (5 minute TTL)
|
||||
@@ -200,8 +201,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
|
||||
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
// The leaf, and with it any AIA issuer URL, comes from an unauthenticated caller.
|
||||
// Every legitimate issuer is embedded below, so the chain engine must never
|
||||
// download one: a fetch would be an attacker-directed request from the backend.
|
||||
chain.ChainPolicy.DisableCertificateDownloads = true;
|
||||
|
||||
foreach (var root in roots)
|
||||
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||
|
||||
@@ -339,13 +339,12 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
|
||||
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
|
||||
// Viewer, read-only Global Reader, delegated reader) receives RedactedCopyForReader()
|
||||
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes stay TenantAdminOrGA below.
|
||||
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes go through the field PATCH
|
||||
// (TenantAdminOrGA, below); there is no full-model PUT (D-290).
|
||||
new("GET", "config/{tenantId}", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
|
||||
|
||||
// ── TenantAdminOrGA ─────────────────────────────────────────────
|
||||
new("PUT", "config/{tenantId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "rules/gather", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
@@ -479,8 +478,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ?tenantId= path — never the no-tenantId aggregate (which the delegated-rescue cannot satisfy).
|
||||
new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
// Changed fields only (D-285); the former full-model PUT/POST is gone.
|
||||
new("PATCH", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config/test-ops-channel", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
|
||||
// Transactional field-level config writes + snapshot list/revert (MCP + web Settings).
|
||||
|
||||
@@ -18,19 +18,19 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// STAGE 1 — SHADOW (<c>SESSION-OWNER-BINDING-SHADOW</c>): nothing is rejected. Every outcome
|
||||
/// is recorded on the request row (denominator), non-Match outcomes are logged as Warnings and
|
||||
/// would-reject outcomes raise a throttled <c>SessionOwnerMismatch</c> ops event. Bindings are
|
||||
/// stamped from stage 1 on so the data exists when enforcement is switched on.
|
||||
/// ENFORCED (stage 2): <see cref="Rejects"/> is the whole rule. It ran as shadow-only first and
|
||||
/// was switched on against four weeks of measured data in which every would-reject outcome was
|
||||
/// an Intune re-enrollment without a wipe. Every outcome is still recorded on the request row,
|
||||
/// non-Match outcomes as Warnings, rejected ones as a throttled <c>SessionOwnerMismatch</c> ops event.
|
||||
/// <para>
|
||||
/// Enforcement (stage 2, separate change, after a long observation window) adds a
|
||||
/// <c>Rejects</c> rule and answers a would-reject with 403 + error code
|
||||
/// A rejected registration answers 403 + error code
|
||||
/// <see cref="AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch"/>, on which
|
||||
/// the agent rotates its session id instead of counting an auth failure. That is what makes the
|
||||
/// one legitimate collision — an Intune re-enrollment WITHOUT a wipe, where <c>session.id</c>
|
||||
/// survives but the device gets a new certificate identity — resolve into a fresh session rather
|
||||
/// than a dead one. Deliberately no <c>Rejects</c> in this file yet: KQL written against the
|
||||
/// shadow field must keep working, and the switch has to be a visible code change.
|
||||
/// the agent rotates its session id instead of counting an auth failure. That is what turns the
|
||||
/// one legitimate collision — a re-enrollment WITHOUT a wipe, where <c>session.id</c> survives but
|
||||
/// the device gets a new certificate identity — into a fresh session rather than a dead one. It
|
||||
/// only works because the agent picks its certificate once per process, before registration: an
|
||||
/// identity change therefore always surfaces at registration, never on a later ingest.
|
||||
/// Grep marker for the sites involved: <c>SESSION-OWNER-BINDING</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this does NOT prove: the serial number is a caller-supplied header on both auth paths.
|
||||
@@ -107,7 +107,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <summary>Whether the caller's announced serial equals the serial on the row. Diagnostic — distinguishes re-enroll-without-wipe from a foreign device on <see cref="SessionOwnershipPolicy.Outcome.MismatchCert"/>.</summary>
|
||||
public bool SerialMatch { get; }
|
||||
|
||||
public bool WouldReject => WouldRejectUnderEnforcement(Outcome);
|
||||
/// <summary>Whether the request is refused (<see cref="Rejects"/>).</summary>
|
||||
public bool Rejected => Rejects(Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,8 +166,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an outcome will block the request once enforcement is on. Recorded on every
|
||||
/// non-Match observation so the shadow data answers "what would we have rejected".
|
||||
/// Whether an outcome blocks the request. This is the enforcement rule, in one place.
|
||||
/// <para>
|
||||
/// Tolerated by design (never reject): <see cref="Outcome.Fresh"/>, <see cref="Outcome.Match"/>,
|
||||
/// <see cref="Outcome.ClaimLegacy"/> (rows from before the binding must not lock their own
|
||||
@@ -174,13 +174,19 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// normal lifecycle), and <see cref="Outcome.CallerUnidentified"/> (nothing to compare).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool WouldRejectUnderEnforcement(string outcome) =>
|
||||
public static bool Rejects(string outcome) =>
|
||||
outcome == Outcome.LegacySerialMismatch
|
||||
|| outcome == Outcome.MismatchBootstrapOwned
|
||||
|| outcome == Outcome.MismatchCert
|
||||
|| outcome == Outcome.MismatchBootstrap
|
||||
|| outcome == Outcome.DowngradeToBootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Retained name for the <c>wouldReject</c> telemetry field. Identical to <see cref="Rejects"/> —
|
||||
/// kept so KQL written during the shadow phase keeps working.
|
||||
/// </summary>
|
||||
public static bool WouldRejectUnderEnforcement(string outcome) => Rejects(outcome);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the caller's identity from a successful validation. Null when the request carried
|
||||
/// no bindable identity (no certificate thumbprint and no bootstrap code).
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Prevents Server-Side Request Forgery (SSRF) by validating webhook URLs
|
||||
/// against private/reserved network ranges before allowing outbound requests.
|
||||
/// Prevents Server-Side Request Forgery (SSRF) for customer-configured outbound URLs: format
|
||||
/// checks at config save time, and an address gate on the webhook client's connections.
|
||||
/// </summary>
|
||||
public static class SsrfGuard
|
||||
{
|
||||
@@ -69,129 +73,123 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the webhook URL hostname via DNS and validates all resolved IPs
|
||||
/// against blocked ranges. Call before every outbound HTTP request.
|
||||
/// Throws <see cref="SsrfException"/> if the destination is blocked.
|
||||
/// Fails closed: DNS resolution errors are treated as blocked.
|
||||
/// <see cref="SocketsHttpHandler.ConnectCallback"/> of the webhook client — the SSRF gate.
|
||||
/// A refused destination surfaces as an <see cref="HttpRequestException"/> whose
|
||||
/// <see cref="Exception.InnerException"/> is the <see cref="SsrfException"/>.
|
||||
/// </summary>
|
||||
public static async Task ValidateDestinationAsync(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new SsrfException("Webhook URL is not a valid URL.");
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttps)
|
||||
throw new SsrfException("Webhook URL must use HTTPS.");
|
||||
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(uri.Host);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw new SsrfException("Could not resolve webhook hostname.");
|
||||
}
|
||||
|
||||
if (addresses.Length == 0)
|
||||
throw new SsrfException("Webhook hostname resolved to no addresses.");
|
||||
|
||||
foreach (var addr in addresses)
|
||||
{
|
||||
if (IsBlockedAddress(addr))
|
||||
throw new SsrfException("Webhook URL targets a private or reserved network.");
|
||||
}
|
||||
}
|
||||
public static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> ConnectToPublicAddress { get; } =
|
||||
CreateConnectCallback(Dns.GetHostAddressesAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the IP address is private, loopback, link-local, multicast,
|
||||
/// cloud metadata, reserved, or an IPv6-mapped version of any of the above.
|
||||
/// Builds the gate: it resolves the host exactly once per connection and connects the socket
|
||||
/// only to the addresses that resolution returned, after every one of them passed
|
||||
/// <see cref="IsBlockedAddress"/> — so the address checked is the address connected to. A
|
||||
/// separate pre-flight resolution would let a DNS-rebinding host answer the check and the
|
||||
/// connect differently. HTTPS only; TLS then runs on the returned stream against the request
|
||||
/// host as usual. <paramref name="isBlocked"/> is widened only by tests that need to reach a
|
||||
/// loopback listener.
|
||||
/// </summary>
|
||||
internal static bool IsBlockedAddress(IPAddress addr)
|
||||
internal static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> CreateConnectCallback(
|
||||
Func<string, CancellationToken, Task<IPAddress[]>> resolve,
|
||||
Func<IPAddress, bool>? isBlocked = null)
|
||||
{
|
||||
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:10.0.0.1 -> 10.0.0.1)
|
||||
if (addr.IsIPv4MappedToIPv6)
|
||||
addr = addr.MapToIPv4();
|
||||
|
||||
// Loopback: 127.0.0.0/8, ::1
|
||||
if (IPAddress.IsLoopback(addr))
|
||||
return true;
|
||||
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (addr.IsIPv6LinkLocal)
|
||||
return true;
|
||||
|
||||
// IPv6 multicast (ff00::/8)
|
||||
if (addr.IsIPv6Multicast)
|
||||
return true;
|
||||
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork)
|
||||
isBlocked ??= IsBlockedAddress;
|
||||
return async (context, cancellationToken) =>
|
||||
{
|
||||
var bytes = addr.GetAddressBytes();
|
||||
if (context.InitialRequestMessage.RequestUri?.Scheme != Uri.UriSchemeHttps)
|
||||
throw new SsrfException("Webhook URL must use HTTPS.");
|
||||
|
||||
// 10.0.0.0/8 (RFC 1918)
|
||||
if (bytes[0] == 10)
|
||||
return true;
|
||||
var host = context.DnsEndPoint.Host;
|
||||
IPAddress[] addresses;
|
||||
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
|
||||
{
|
||||
addresses = new[] { literal };
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
addresses = await resolve(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
throw new SsrfException("Could not resolve webhook hostname.");
|
||||
}
|
||||
}
|
||||
|
||||
// 172.16.0.0/12 (RFC 1918)
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
|
||||
return true;
|
||||
if (addresses.Length == 0)
|
||||
throw new SsrfException("Webhook hostname resolved to no addresses.");
|
||||
|
||||
// 192.168.0.0/16 (RFC 1918)
|
||||
if (bytes[0] == 192 && bytes[1] == 168)
|
||||
return true;
|
||||
if (addresses.Any(isBlocked))
|
||||
throw new SsrfException("Webhook URL targets a private or reserved network.");
|
||||
|
||||
// 169.254.0.0/16 (link-local, includes Azure IMDS 169.254.169.254)
|
||||
if (bytes[0] == 169 && bytes[1] == 254)
|
||||
return true;
|
||||
|
||||
// 0.0.0.0/8 (current network)
|
||||
if (bytes[0] == 0)
|
||||
return true;
|
||||
|
||||
// 100.64.0.0/10 (Carrier-grade NAT, RFC 6598)
|
||||
if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127)
|
||||
return true;
|
||||
|
||||
// 192.0.0.0/24 (IETF Protocol Assignments)
|
||||
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0)
|
||||
return true;
|
||||
|
||||
// 192.0.2.0/24 (TEST-NET-1)
|
||||
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2)
|
||||
return true;
|
||||
|
||||
// 198.18.0.0/15 (Benchmark testing)
|
||||
if (bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19))
|
||||
return true;
|
||||
|
||||
// 198.51.100.0/24 (TEST-NET-2)
|
||||
if (bytes[0] == 198 && bytes[1] == 51 && bytes[2] == 100)
|
||||
return true;
|
||||
|
||||
// 203.0.113.0/24 (TEST-NET-3)
|
||||
if (bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113)
|
||||
return true;
|
||||
|
||||
// 224.0.0.0/4 (Multicast) and 240.0.0.0/4 (Reserved)
|
||||
if (bytes[0] >= 224)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (addr.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var bytes = addr.GetAddressBytes();
|
||||
|
||||
// fc00::/7 (Unique local address)
|
||||
if ((bytes[0] & 0xFE) == 0xFC)
|
||||
return true;
|
||||
|
||||
// :: (unspecified)
|
||||
if (addr.Equals(IPAddress.IPv6None) || addr.Equals(IPAddress.IPv6Any))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// IPv4: every block of the IANA IPv4 Special-Purpose Address Registry plus multicast and
|
||||
// 240/4 (which holds the limited broadcast).
|
||||
private static readonly IPNetwork[] BlockedIpv4 =
|
||||
{
|
||||
IPNetwork.Parse("0.0.0.0/8"), // "this network"
|
||||
IPNetwork.Parse("10.0.0.0/8"), // private use
|
||||
IPNetwork.Parse("100.64.0.0/10"), // shared address space (CGNAT)
|
||||
IPNetwork.Parse("127.0.0.0/8"), // loopback
|
||||
IPNetwork.Parse("169.254.0.0/16"), // link-local, holds the cloud metadata endpoints
|
||||
IPNetwork.Parse("172.16.0.0/12"), // private use
|
||||
IPNetwork.Parse("192.0.0.0/24"), // IETF protocol assignments
|
||||
IPNetwork.Parse("192.0.2.0/24"), // documentation (TEST-NET-1)
|
||||
IPNetwork.Parse("192.31.196.0/24"), // AS112-v4
|
||||
IPNetwork.Parse("192.52.193.0/24"), // AMT
|
||||
IPNetwork.Parse("192.88.99.0/24"), // deprecated 6to4 relay anycast
|
||||
IPNetwork.Parse("192.168.0.0/16"), // private use
|
||||
IPNetwork.Parse("192.175.48.0/24"), // direct delegation AS112
|
||||
IPNetwork.Parse("198.18.0.0/15"), // benchmarking
|
||||
IPNetwork.Parse("198.51.100.0/24"), // documentation (TEST-NET-2)
|
||||
IPNetwork.Parse("203.0.113.0/24"), // documentation (TEST-NET-3)
|
||||
IPNetwork.Parse("224.0.0.0/4"), // multicast
|
||||
IPNetwork.Parse("240.0.0.0/4"), // reserved, limited broadcast
|
||||
};
|
||||
|
||||
// IPv6: only global unicast 2000::/3 is allowed at all — one rule that refuses loopback,
|
||||
// unspecified, IPv4-mapped/-compatible, NAT64, discard, unique-local, link-/site-local and
|
||||
// multicast — and inside it the IANA special-purpose blocks are refused.
|
||||
private static readonly IPNetwork GlobalUnicastIpv6 = IPNetwork.Parse("2000::/3");
|
||||
|
||||
private static readonly IPNetwork[] BlockedGlobalUnicastIpv6 =
|
||||
{
|
||||
IPNetwork.Parse("2001::/23"), // IETF protocol assignments (Teredo, benchmarking, ORCHID, AMT, AS112, ...)
|
||||
IPNetwork.Parse("2001:db8::/32"), // documentation
|
||||
IPNetwork.Parse("2002::/16"), // 6to4
|
||||
IPNetwork.Parse("2620:4f:8000::/48"), // direct delegation AS112
|
||||
IPNetwork.Parse("3fff::/20"), // documentation
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True for every address a webhook must not connect to: all IANA special-purpose space, and
|
||||
/// any IPv6 address outside global unicast or carrying a zone id.
|
||||
/// </summary>
|
||||
internal static bool IsBlockedAddress(IPAddress addr) => addr.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => BlockedIpv4.Any(n => n.Contains(addr)),
|
||||
AddressFamily.InterNetworkV6 => addr.ScopeId != 0
|
||||
// Refused before any IPNetwork check: IPNetwork.Contains judges an IPv4-mapped
|
||||
// address by its IPv4 form, so 2000::/3 "contains" ::ffff:8.8.8.8.
|
||||
|| addr.IsIPv4MappedToIPv6
|
||||
|| !GlobalUnicastIpv6.Contains(addr)
|
||||
|| BlockedGlobalUnicastIpv6.Any(n => n.Contains(addr)),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -78,8 +78,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
try
|
||||
{
|
||||
// Save the default configuration via repository
|
||||
await _configRepo.SaveAdminConfigurationAsync(defaultConfig);
|
||||
// Create-only: a concurrent first write (or a row this read missed) is never replaced.
|
||||
await _configRepo.CreateAdminConfigurationIfMissingAsync(defaultConfig);
|
||||
|
||||
_cache.Set(CacheKey, defaultConfig, CacheDuration);
|
||||
|
||||
@@ -191,38 +191,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves global admin configuration and syncs rate limit to all tenant configurations
|
||||
/// Changes the admin configuration through the repository's changed-columns write (D-285):
|
||||
/// fresh read, <paramref name="mutate"/> on a copy, only the columns it changed are written.
|
||||
/// Never pass a configuration object around to save it — a cached or page-loaded copy is
|
||||
/// exactly what used to revert other writers' changes. Invalidates this instance's cache
|
||||
/// after a write; other instances catch up within the 5-minute TTL.
|
||||
/// </summary>
|
||||
public async Task SaveConfigurationAsync(AdminConfiguration config)
|
||||
public virtual async Task<AdminConfigurationUpdateResult> UpdateAsync(
|
||||
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
|
||||
{
|
||||
if (config == null)
|
||||
var result = await _configRepo.UpdateAdminConfigurationAsync(mutate, updatedBy, source);
|
||||
if (result.Error == null && result.ChangedColumns.Count > 0)
|
||||
{
|
||||
throw new ArgumentException("Configuration is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _configRepo.SaveAdminConfigurationAsync(config);
|
||||
|
||||
// Invalidate cache
|
||||
_cache.Remove(CacheKey);
|
||||
|
||||
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
|
||||
|
||||
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
|
||||
// per-tenant limit is computed at read time as `tenantOverride ?? global`
|
||||
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
|
||||
// user path). This removes the former background sync job — and the clobbering
|
||||
// foot-gun where a per-tenant edit to the base field was overwritten on the next
|
||||
// global save.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving admin configuration");
|
||||
throw;
|
||||
_logger.LogInformation(
|
||||
"Admin configuration changed by {UpdatedBy}: {Columns}",
|
||||
updatedBy, string.Join(", ", result.ChangedColumns));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -221,32 +221,47 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Persists a homing change (<paramref name="targetClientId"/> null = legacy) with the full
|
||||
/// side-effect chain: fresh config re-read (shrinks the read-modify-write window), save,
|
||||
/// side-effect chain: conditional write on the fresh row (<see cref="TenantConfigurationService.UpdateAsync"/>),
|
||||
/// Graph cache invalidation (token layer first, via the single fresh-read entry point),
|
||||
/// audit entry, telemetry, ops event. No-ops when the tenant is already at the target.
|
||||
/// Throws when no configuration row exists: this is a whole-entity read-modify-write, and
|
||||
/// the cached/fail-open reader would let a flip rewind another writer's changes (up to the
|
||||
/// 5-minute TTL) or save a default row over a deleted tenant.
|
||||
/// audit entry, telemetry, ops event. No-ops when the tenant is already at the target or is
|
||||
/// being offboarded. Throws when no configuration row exists (a flip never creates one) or
|
||||
/// when every conditional write lost to a concurrent change.
|
||||
/// </summary>
|
||||
public virtual async Task FlipAsync(string tenantId, string? targetClientId, string actorUpn, string reason, bool forced = false)
|
||||
{
|
||||
var config = await _tenantConfigService.GetConfigurationFreshAsync(tenantId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"App-homing flip aborted for tenant {tenantId} — no tenant configuration row exists");
|
||||
var normalizedTarget = EntraAppRegistry.NormalizeClientId(targetClientId);
|
||||
var oldValue = config.HomedAppClientId;
|
||||
if (string.Equals(oldValue, normalizedTarget, StringComparison.OrdinalIgnoreCase))
|
||||
string? oldValue = null;
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
return;
|
||||
if (string.Equals(row.HomedAppClientId, normalizedTarget, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
oldValue = row.HomedAppClientId;
|
||||
row.HomedAppClientId = normalizedTarget;
|
||||
row.UpdatedBy = actorUpn;
|
||||
return true;
|
||||
}, "app-homing", reason);
|
||||
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.Updated:
|
||||
break;
|
||||
case TenantConfigUpdateStatus.Declined:
|
||||
return; // already at the target
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
_logger.LogWarning("App-homing flip skipped for tenant {TenantId} — offboarding in progress", tenantId);
|
||||
return;
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
throw new InvalidOperationException(
|
||||
$"App-homing flip aborted for tenant {tenantId} — no tenant configuration row exists");
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"App-homing flip for tenant {tenantId} lost every conditional write to concurrent changes");
|
||||
}
|
||||
var config = update.Config!;
|
||||
|
||||
var oldLabel = oldValue ?? "(legacy)";
|
||||
var newLabel = normalizedTarget ?? "(legacy)";
|
||||
|
||||
config.HomedAppClientId = normalizedTarget;
|
||||
config.UpdatedBy = actorUpn;
|
||||
await _tenantConfigService.SaveConfigurationAsync(config, "app-homing", reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId} app-reg homing flipped: {Old} -> {New} (by {User}, reason {Reason}, forced {Forced})",
|
||||
tenantId, oldLabel, newLabel, actorUpn, reason, forced);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user