Compare commits

..

14 Commits

Author SHA1 Message Date
Oliver Kieselbach e01e7abc2b bump pointer: internal, .claude/commands (D-434 registration 410: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:06:11 +01:00
Oliver Kieselbach 512b145484 Backend: the comment on register-session's 410 sits on its method
The summary of the 410 response stood above ClampStartedAt and promised
that the agent treats registration and telemetry alike. It now sits on
WriteSessionLockedAsync and says what the agent does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:40 +01:00
Oliver Kieselbach 8ece30631a Agent: a registration answered with 410 ends monitoring; a WhiteGlove Part 2 start registers a new session
register-session answers 410 while a deleted session's tombstone is fresh.
The agent treated that like a network failure: six attempts, exit 7,
everything kept for the next boot. A WhiteGlove resume never left that
loop, because the 48-hour break is skipped for it.

A 410 that echoes the request's correlation id is now its own signal. The
start sends one report (RegistrationRejectedSessionGone) and ends
monitoring with a forced cleanup, as the telemetry path already does on
410. A WhiteGlove Part 2 start rotates its session id and registers again
instead, so the user phase is observed; agent_started names the reason in
sessionRotationReason. A 410 without the echo stays a retried failure.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:39 +01:00
Oliver Kieselbach c9078d00b6 bump pointer: internal, .claude/commands (D-433 released: backend 1.5.1329, web and MCP 1.6.595 on 1ff68dc)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:30:25 +01:00
Oliver Kieselbach 1ff68dc0af bump pointer: internal, .claude/commands (D-433 script-name lookups: decision, plan, backlog, health marker)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:08:01 +01:00
Oliver Kieselbach 7c8204357b Web: the session page starts fresh for every session
A link from one session to another only changes ?id=, so the route stays
mounted, and the append-only event list kept the previous session's
events: both sessions ended up in one timeline. The page content is now
keyed by the session id.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:48 +01:00
Oliver Kieselbach dfcf7ad8ba Web: one script-name lookup per session view, one follow-up after a time limit
The session page loads events page by page, and the hook fired a lookup
for every page that added a script. It now waits for the complete event
walk and asks once, with every script of the session.

When the backend reports that a time limit cut the lookup short, the hook
asks once more after 5 s. The follow-up never takes away a name the first
answer had, and a session switch drops a pending one.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:35 +01:00
Oliver Kieselbach 54d4c0bd4c Script names: an incomplete lookup leaves a trace and reports its time limit
A lookup that ends without an answer for every ref, because one of the
resolver's time limits fired or Graph or the token context failed, writes
one Warning with the reasons. These paths logged at Information or not at
all, and nothing collects worker logs below Warning.

The response carries timedOut when a time limit cut the lookup short, the
request may carry retry, and the request row gets ScriptNameLookup
(timedOut / retry / retryTimedOut), so a follow-up lookup can be told from
a first one.

Also:
- The token cap is 3 s instead of 1.5 s: the first mint on a cold instance
  often needs longer, and a lookup without a token returns no name at all.
- A not-found id is kept as long as a name (7 days) instead of being asked
  again after every cleanup run.
- A list pull whose budget ends between two pages is no longer stored as
  the tenant's complete list.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:10 +01:00
Oliver Kieselbach 11bc0ec2ab bump pointer: internal, .claude/commands (D-432 released: backend 1.5.1328, web and MCP on 7e9b0ce)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 13:06:27 +01:00
Oliver Kieselbach 7e9b0cea54 bump pointer: internal, .claude/commands (D-432 configuration read after a write: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:29:14 +01:00
Oliver Kieselbach c115cb354e MCP: get_tenant_config reads the stored configuration
The tool sends X-Tenant-Config: stored, so the read that checks an
update_tenant_config does not come from a backend instance's cached copy.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:58 +01:00
Oliver Kieselbach 9cfe3fb74d Backend: a saved tenant configuration reaches the next request that needs it
The tenant configuration stays cached per instance for five minutes, and a
write clears only the cache of the instance that served it. Two readers no
longer act on a copy another instance's write has outdated:

- A signed-in request carrying X-Tenant-Config: stored (the portal sends it for
  a few minutes after a configuration write, the MCP on get_tenant_config)
  reads the stored row of the acting tenant, and of the target tenant once the
  cross-tenant check has passed, into the instance cache before authorization
  and the function read it. A storage failure keeps the cached copy. The
  header grants nothing: it costs the caller's own request one read.
- The agent gate confirms a refusal the configuration decides (tenant
  suspended, no validation switched on, manufacturer or model not allowed,
  bootstrap not enabled) against the stored row first, at most once per 15 s
  per tenant and instance, and runs the device chain once more when the stored
  row switches on a validation it had not asked. A device enrolled right after
  an admin allowed it is no longer turned away for up to five minutes.
  Requests that pass read nothing extra. The bootstrap routes check their
  feature switch through one helper that confirms the same way.

GET config/{tenantId} and the feature flags answer a storage failure with an
error instead of a synthesized default configuration.

Request-row dimensions: ConfigRefresh (stored row read on the caller's hint),
ConfigConfirm (averted / confirmed / rerun on agent requests) and
ConfigPatchUnchanged on the field patch (sent fields the row already held).

A guard test keeps every function that writes the tenant row on a route the
portal recognizes as a configuration write.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:55 +01:00
Oliver Kieselbach 892ff4c1e1 Portal: saved settings show at once, a card saves only its edits, setup guide replay
After a successful write of the tenant configuration the browser asks for the
stored configuration on its API calls for six minutes (X-Tenant-Config: stored,
lib/storedConfigHint.ts). The backend caches a configuration per instance, so a
page loaded right after a save, for example on the way back from the admin
consent, could show the values from before it. The mark lives in localStorage:
it survives the redirect and holds in other tabs. The header leaves the fetch
cache mode alone, and a backend that does not know it ignores it.

A settings card sends exactly the fields that were edited. The first save of a
card used to send every field the configuration had never stored, with the
form's default; a page showing an older configuration could write such a
default over a value saved elsewhere. What a card sends and what counts as
unsaved is one computation now (sectionSavePayload). A switch that saves on
change always sends the value that was clicked. The form's defaults are pinned
against the model's and the agent's defaults in the generated manifest.

Setup guide: "Go through the steps again" on the completion screen starts it at
step 1, and ?setup-guide=settings|hardware|validation|deploy opens it at that
step for tenant admins. It works on the tenant's current configuration and
resets nothing. The dialog shows its own writes ("Back" after "Apply" shows
"Already on"), recording a step drops the cached feature flags, the hardware
step asks for at least one manufacturer instead of claiming an empty list
rejects every device, and "The approval was not completed" needs a loaded
configuration.

The duplicate baseline loses one entry.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:44 +01:00
Oliver Kieselbach 2cb181734a bump pointer: internal (D-431 released with agent 2.0.1502)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:33:53 +01:00
80 changed files with 3303 additions and 272 deletions
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
{
// The integrity report goes out post-config, seconds before the registration is refused.
var clock = Clock();
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
clock.Advance(TimeSpan.FromSeconds(5));
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
Assert.Equal(410, client.Reports[1].HttpStatusCode);
// Key dedup still applies: a second one is held back.
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
{
@@ -0,0 +1,167 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
/// </summary>
public sealed class SessionGoneAtRegistrationTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly SessionIdPersistence _persistence;
private readonly string _stateDir;
private readonly List<string> _sequence = new List<string>();
public SessionGoneAtRegistrationTests()
{
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
_persistence = new SessionIdPersistence(_tmp.Path);
_persistence.GetOrCreate(_logger);
_stateDir = Path.Combine(_tmp.Path, "State");
}
public void Dispose() => _tmp.Dispose();
private sealed class RecordingEmergencyReporter : EmergencyReporter
{
private readonly List<string> _sequence;
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
new List<(AgentErrorType, string, int?, int)>();
public RecordingEmergencyReporter(List<string> sequence)
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
{
_sequence = sequence;
}
public override Task TrySendAsync(
AgentErrorType errorType,
string message,
int? httpStatusCode = null,
long? sequenceNumber = null,
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null,
double? sessionAgeHours = null,
RegistrationFailureSummary? priorRegistrationFailure = null)
{
Reports.Add((errorType, message, httpStatusCode, attempts));
_sequence.Add("report");
return Task.CompletedTask;
}
}
private CleanupService CleanupFactory()
{
_sequence.Add("cleanup");
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
}
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
[Theory]
[InlineData(true)]
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
{
_persistence.SaveWhiteGloveComplete(_logger);
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
var reporter = new RecordingEmergencyReporter(_sequence);
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
// The report leaves before the cleanup deletes the agent.
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
var report = Assert.Single(reporter.Reports);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
Assert.Equal(410, report.HttpStatusCode);
Assert.Contains("410", report.Message);
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
Assert.False(_persistence.IsWhiteGloveResume());
Assert.Null(_persistence.LoadSessionCreatedAt());
// The next start ends at the bootstrap guard, exactly as after the emergency break.
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
}
[Fact]
public void Ends_monitoring_without_a_reporter()
{
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
Assert.Equal(new[] { "cleanup" }, _sequence);
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
}
[Theory]
[InlineData(SessionRotationReasons.SessionGone)]
[InlineData(SessionRotationReasons.OwnerMismatch)]
public void Agent_started_names_why_the_session_was_rotated(string reason)
{
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
Assert.Equal(true, data["sessionRotated"]);
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
Assert.Equal(reason, data["sessionRotationReason"]);
}
[Fact]
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
{
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
Assert.Equal(false, data["sessionRotated"]);
Assert.False(data.ContainsKey("sessionRotationReason"));
}
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
{
var sink = new FakeSignalIngressSink();
LifecycleEmitters.EmitAgentStarted(
new InformationalEventPost(sink, SystemClock.Instance, _logger),
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
"2.0.test",
remoteConfigService: null,
_logger,
rotatedFromSessionId: rotatedFromSessionId,
sessionRotationReason: sessionRotationReason);
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
}
}
}
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
}
private static BackendRefusalException TenantConflict()
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(1, tracker.ConsecutiveFailures);
}
// ── 410 at registration: the backend deleted the session ────────────
private static BackendSessionGoneException SessionGone()
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
[Fact]
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(410, result.HttpStatusCode);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(0, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Null(result.RotatedFromSessionId);
Assert.Null(result.RotationReason);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
{
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 rotations = 0;
var apiClient = new FakeApiClient(
attempt => attempt == 1
? throw SessionGone()
: 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: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
}
[Fact]
public async Task Second_session_gone_after_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
}
[Fact]
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => throw new IOException("disk full"),
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Null(result.RotatedFromSessionId);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
}
[Fact]
public async Task Fails_after_six_exceptions_without_auth_error()
{
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
}
// ── 410: session gone, only when the backend said so ────────────────
[Fact]
public async Task Backend_410_on_registration_is_session_gone()
{
using var client = BuildClient(
HttpStatusCode.Gone,
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
Assert.Equal(410, ex.StatusCode);
}
[Fact]
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
{
// Answered on the network path: the caller would end monitoring on a session-gone
// signal, so it must stay an ordinary, retried failure.
using var client = BuildClient(
HttpStatusCode.Gone,
"<html><head><title>Gone</title></head><body>Gone</body></html>",
"text/html",
fromBackend: false);
await Assert.ThrowsAsync<HttpRequestException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
ThrowOnSessionGone(response, correlationId);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
errorCode);
}
/// <summary>
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
/// session was deleted or is being deleted. Only a response that echoes this request's
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
/// </summary>
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
{
if (response.StatusCode != HttpStatusCode.Gone)
return;
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
return;
throw new BackendSessionGoneException(
"Backend returned 410 Gone: the session was deleted or is being deleted.",
(int)response.StatusCode);
}
/// <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.
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
ErrorCode = errorCode;
}
}
/// <summary>
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
/// </summary>
public class BackendSessionGoneException : Exception
{
/// <summary>HTTP status code returned by the backend (410).</summary>
public int StatusCode { get; }
public BackendSessionGoneException(string message, int statusCode) : base(message)
{
StatusCode = statusCode;
}
}
}
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return;
}
// The session-unknown report is terminal and sent at most once per process (the
// A session-gone report is terminal and sent at most once per process (the
// termination follows immediately): a routine report minutes earlier must not cost
// the last word about the session. Only the cooldown is waived; key dedup and the
// per-session maximum still apply.
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
if (!exemptFromCooldown && _lastReportTime != null &&
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// 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>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
/// enrollment is only now continuing) the session is rotated like above and registered
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</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>
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// Optional pre-registration link wait used by tests. Production callers pass
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
/// </param>
/// <param name="rotateOnSessionGone">
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
/// </param>
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
BackendApiClient apiClient,
AgentConfiguration agentConfig,
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
Func<Exception, Task> onTerminalTransportFailure = null,
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
Func<AgentLogger, Task> networkLinkWait = null,
Func<string> rotateSession = null)
Func<string> rotateSession = null,
bool rotateOnSessionGone = false)
{
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
string lastError = null;
Exception lastException = null;
string rotatedFromSessionId = null;
string rotationReason = null;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (response != null && response.Success)
{
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
}
lastError = response?.Message ?? "(null response)";
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
$"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.");
rotationReason = SessionRotationReasons.OwnerMismatch;
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
catch (BackendSessionGoneException ex)
{
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
{
var goneSessionId = registration.SessionId;
string newSessionId = null;
try
{
newSessionId = rotateSession();
}
catch (Exception rotateEx)
{
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
}
if (newSessionId != null)
{
rotatedFromSessionId = goneSessionId;
rotationReason = SessionRotationReasons.SessionGone;
logger.Warning(
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
}
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
}
catch (BackendRefusalException ex)
{
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
Failed = 2,
/// <summary>
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
/// caller ends monitoring and cleans up.
/// </summary>
SessionGone = 3,
}
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
public static class SessionRotationReasons
{
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
public const string OwnerMismatch = "owner_mismatch";
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
public const string SessionGone = "session_gone";
}
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
/// <summary>
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
/// registration. Null on the normal path.
/// </summary>
public string RotatedFromSessionId { get; }
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
public string RotationReason { get; }
/// <summary>
/// True when the run ended on a 401/403 that something on the network path answered (TLS
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
int httpStatusCode,
string errorMessage,
string rotatedFromSessionId = null,
bool isIntercepted = false)
bool isIntercepted = false,
string rotationReason = null)
{
Outcome = outcome;
Response = response;
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
ErrorMessage = errorMessage;
RotatedFromSessionId = rotatedFromSessionId;
IsIntercepted = isIntercepted;
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
}
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
public static SessionRegistrationResult Succeeded(
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
=> new SessionRegistrationResult(
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
public static SessionRegistrationResult SessionGone(int statusCode, string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
/// previous session already completed enrollment — written by
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
/// <para>
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
/// break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
/// so all three leave the device in the same state. Never throws.
/// and the session-gone end force it), and deletes the persisted session files. Shared by
/// the session-age emergency break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
/// so all four leave the device in the same state. Never throws.
/// </summary>
internal static void EndMonitoringAtStart(
string stateDirectory,
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
// starts than the buffer allows cleans up like the emergency break (state folder +
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
// cleanup factory) and still exits 6. A registration the backend answers with 410
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
var registration = Runtime.BackendSessionRegistration.Register(
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
sessionPersistence: bootstrap.SessionPersistence,
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
stateDirectory: stateSubdir,
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
if (registration.ShouldExit)
{
return registration.ExitCode;
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
priorRegistrationFailures: registration.PriorFailures);
priorRegistrationFailures: registration.PriorFailures,
sessionRotationReason: registrationResult?.RotationReason);
// Wire-visible signal that the agent is running on defaults/cache
// rather than the live tenant config — closes the cold-start
// blind spot observed in session 8f2bef72 (2026-05-19).
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// the next boot. The failed run is recorded for the emergency break report. A registration
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
/// session instead, because its enrollment is only now continuing.
/// A successful registration after failed starts reports them on <c>agent_started</c>
/// (<see cref="ClearAfterSuccess"/>).
/// </summary>
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
public static SessionRegistrationOutcomeResult Register(
AgentConfiguration agentConfig,
BackendAuthBundle auth,
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
SessionIdPersistence sessionPersistence = null,
string configFetchOutcome = null,
string stateDirectory = null,
Func<CleanupService> cleanupServiceFactory = null)
Func<CleanupService> cleanupServiceFactory = null,
bool isWhiteGloveResume = false)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
rotateSession: rotateSession)
rotateSession: rotateSession,
rotateOnSessionGone: isWhiteGloveResume)
.GetAwaiter().GetResult();
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
}
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
{
// The report goes out through the auth bundle's client: dispose only afterwards.
var exitCode = ConcludeSessionGone(
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
cleanupServiceFactory, consoleMode, logger);
try { mtlsHttpClient?.Dispose(); } catch { }
try { auth.BackendApiClient?.Dispose(); } catch { }
return SessionRegistrationOutcomeResult.Exit(exitCode);
}
var record = RecordFailedRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
/// <summary>
/// The end of a start whose registration the backend answered with 410: the session was
/// deleted. One report over the emergency channel (a deleted session has no timeline to
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
/// </summary>
internal static int ConcludeSessionGone(
AgentConfiguration agentConfig,
EmergencyReporter emergencyReporter,
SessionIdPersistence sessionPersistence,
string stateDirectory,
Func<CleanupService> cleanupServiceFactory,
bool consoleMode,
AgentLogger logger)
{
logger.Error(
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
if (consoleMode)
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.RegistrationRejectedSessionGone,
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
logger);
Program.EndMonitoringAtStart(
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
cleanupReason: "session_gone");
return 0;
}
/// <summary>
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
RemoteConfigService remoteConfigService,
AgentLogger logger,
string rotatedFromSessionId = null,
PriorRegistrationFailures priorRegistrationFailures = null)
PriorRegistrationFailures priorRegistrationFailures = null,
string sessionRotationReason = null)
{
try
{
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
// id (session_owner_mismatch) and this run rotated to a new session before
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
// True when the backend refused the persisted session id and this run rotated
// to a new session before registering: session_owner_mismatch (re-enroll
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
// sessionRotationReason below names which.
{ "sessionRotated", rotatedFromSessionId != null },
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
// remoteConfigFetched answers "is the agent running on a live-fetched
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (recovered)
data["remoteConfigRecovered"] = true;
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
data["sessionRotationReason"] = sessionRotationReason;
// Which bootstrap installed this agent (installer and loader versions, MSI or
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
// fleet's share of old or directly assigned installers countable.
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
};
}
// Bounded blocking send for the one session-gone report: the terminate that follows ends
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
private const int SessionGoneReportAttempts = 2;
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
/// <summary>
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
if (emergencyReporter != null)
{
try
{
emergencyReporter
.TrySendAsync(
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: SessionGoneReportAttempts,
perAttemptTimeout: SessionGoneReportTimeout,
retryDelay: SessionGoneReportRetryDelay)
.Wait(SessionGoneReportBudget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
logger);
var terminateAction = new ServerAction
{
@@ -0,0 +1,50 @@
using System;
using System.Net;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Runtime
{
/// <summary>
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
/// the caller ends the process right after and the cleanup deletes the agent, so a
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
/// reservation (ONE report). Never throws.
/// </summary>
internal static class SessionGoneReport
{
private const int Attempts = 2;
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
public static void SendBlocking(
EmergencyReporter emergencyReporter,
AgentErrorType errorType,
string message,
AgentLogger logger)
{
if (emergencyReporter == null) return;
try
{
emergencyReporter
.TrySendAsync(
errorType,
message,
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: Attempts,
perAttemptTimeout: PerAttemptTimeout,
retryDelay: RetryDelay)
.Wait(Budget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
}
}
@@ -0,0 +1,228 @@
using System.Net;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
/// tenant, manufacturer "Contoso", model "TestBook".
/// </summary>
public sealed class ConfigConfirmBeforeRefusalTests
{
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
{
var config = new TenantConfiguration
{
TenantId = TenantId,
AllowInsecureAgentRequests = true,
ManufacturerWhitelist = "*",
ModelWhitelist = "*",
};
change?.Invoke(config);
return config;
}
/// <summary>
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
/// service, a confirming read makes the stored row the cached one.
/// </summary>
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var current = cached;
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
{
if (stored != null) current = stored;
return stored;
});
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
var validator = new SecurityValidator(
configService: config.Object,
adminConfigService: adminConfig.Object,
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: Mock.Of<ILogger>(),
bootstrapSessionService: null,
deviceAssociationValidator: null);
return new Gate(validator, config, new Dictionary<object, object>());
}
private static Task<SecurityValidationResult> Validate(Gate gate)
=> gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
private static string? Stamp(Gate gate)
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
// ---------------------------------------------------------------- a refusal is confirmed first
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
{
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
};
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
{
var gate = Build(cached: outdated, stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
{
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
{
// Inside the confirm interval, or storage failed: nothing newer to decide on.
var gate = Build(cached: refusing, stored: null);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Null(Stamp(gate));
}
// ---------------------------------------------------------------- the hot path stays untouched
[Fact]
public async Task A_request_the_cached_copy_admits_reads_nothing()
{
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
{
// A certificate of another tenant: the configuration admits, the certificate does not.
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.False(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
}
// ---------------------------------------------------------------- the device validation chain
[Fact]
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
{
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
// nothing admits the device, on either pass. What counts here is the control flow.
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
var gate = Build(cached, stored);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Fact]
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
{
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
var result = await Validate(gate);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
{
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
}
// ---------------------------------------------------------------- the patch marker
[Fact]
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
{
static PatchOutcome Applied(params string[] changed)
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
// Wire names are camelCase, applied names are the model's property names.
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
}
}
@@ -0,0 +1,55 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Graph;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
/// normal case and carries neither the flag nor a marker.
/// </summary>
public class GetScriptDisplayNamesFunctionTests
{
private const string TenantId = "00000000-0000-4000-8000-000000000002";
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
[Theory]
[InlineData(false, false, null)]
[InlineData(true, false, "timedOut")]
[InlineData(false, true, "retry")]
[InlineData(true, true, "retryTimedOut")]
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
bool timedOut, bool isFollowUp, string? expectedMarker)
{
var resolver = new Mock<IScriptDisplayNameResolver>();
resolver
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
// The key is left out, not false, when no time limit fired.
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
if (timedOut) Assert.True(flag.GetBoolean());
if (expectedMarker == null)
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
else
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
}
}
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
}
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
public HashSet<string> HangingTenants { get; } = new();
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
public Exception? TokenContextThrows { get; set; }
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
{
if (TokenContextThrows != null) throw TokenContextThrows;
if (HangingTenants.Contains(tenantId))
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
}
ContextByTenant.TryGetValue(tenantId, out var ctx);
return Task.FromResult(ctx);
return ctx;
}
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
@@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.Single(result);
Assert.Null(result[refs[0]]);
Assert.Single(result.Names);
Assert.Null(result.Names[refs[0]]);
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
Assert.False(result.TimedOut);
}
[Fact]
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
fix.Detector.HangingTenants.Add(TenantId);
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
var line = Assert.Single(fix.IncompleteLines);
Assert.Contains($"tenant={TenantId} ", line);
Assert.Contains("reasons=TokenTimeout ", line);
Assert.Contains("requested=1 ", line);
Assert.True(result.TimedOut);
}
[Fact]
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture();
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
}
[Fact]
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("MyScript.ps1", result[r]);
Assert.Equal("MyScript.ps1", result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Bootstrap", result[r]);
Assert.Equal("Bootstrap", result.Names[r]);
Assert.Single(fix.Http.Requests);
// Both list entries cached for future calls.
Assert.Equal(2, fix.Repo.Entries.Count);
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Empty(fix.IncompleteLines);
}
[Fact]
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
{
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
// as "not found" would drop their names.
var fix = new Fixture();
fix.GivenPermission();
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
Assert.Equal("Listed", result.Names[listed]);
Assert.Equal("Unlisted", result.Names[unlisted]);
Assert.Equal(2, fix.Http.Requests.Count);
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
Assert.Empty(fix.IncompleteLines);
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Http.WhenTimedOut("deviceManagementScripts?");
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Fact]
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
{
// A list cut after its first page is incomplete. Stored together with the meta row it
// would count as the tenant's whole list for a week.
var fix = new Fixture();
fix.GivenPermission();
using var caller = new CancellationTokenSource();
fix.Http.OnRequest = _ => caller.Cancel();
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
var r = new ScriptRef(ScriptKind.Platform, "id-1");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.True(result.TimedOut);
}
[Fact]
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Net-New", result[r]);
Assert.Equal("Net-New", result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
// Per-ID fetch also writes to cache.
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
fix.Http.WhenTimedOut("deviceHealthScripts/first");
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var refs = new[]
{
new ScriptRef(ScriptKind.Remediation, "first"),
new ScriptRef(ScriptKind.Remediation, "second"),
};
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Theory]
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Null(first.Names[r]);
Assert.Null(second.Names[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
Assert.Empty(result);
Assert.Empty(result.Names);
Assert.Empty(fix.Http.Requests);
}
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
}
[Fact]
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]); // per-ID never ran
Assert.Null(result.Names[r]); // per-ID never ran
Assert.Single(fix.Http.Requests); // only the failed full-pull
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
}
[Fact]
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result[r]));
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests); // second call never issued
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut);
}
[Fact]
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
public FakeScriptNameCacheRepository Repo { get; } = new();
public StubHttpMessageHandler Http { get; } = new();
public StubHttpClientFactory HttpFactory { get; }
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
public ScriptDisplayNameResolver Sut { get; }
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
public Fixture()
public Fixture(TimeSpan? tokenAcquireBudget = null)
{
HttpFactory = new StubHttpClientFactory(Http);
Sut = new ScriptDisplayNameResolver(
Detector, Repo, HttpFactory,
NullLogger<ScriptDisplayNameResolver>.Instance,
Logger.Object,
new TelemetryClient(new TelemetryConfiguration()),
new FakeTimeProvider(FixedNow));
new FakeTimeProvider(FixedNow),
tokenAcquireBudget);
}
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
public List<string> IncompleteLines => Logger.Invocations
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
.ToList();
public void GivenPermission()
{
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
/// <list type="bullet">
/// <item>Positive rows older than 7 days are removed.</item>
/// <item>Positive rows younger than 7 days are kept.</item>
/// <item>NotFound rows older than 1 hour are removed.</item>
/// <item>NotFound rows younger than 1 hour are kept.</item>
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
/// </list>
/// </summary>
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
}
[Fact]
public async Task Deletes_notfound_rows_older_than_1_hour()
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
{
// A not-found row that goes after hours instead of days sends one Graph lookup per
// unknown ID on the first view after every cleanup run.
var fix = new Fixture();
var deletedId = "deleted-1h-ago";
var recentId = "recent-30min";
var deletedId = "not-found-8-days-ago";
var recentId = "not-found-2-hours-ago";
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
ScriptId = deletedId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
IsNotFound = true,
};
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
Kind = ScriptKind.Remediation,
ScriptId = recentId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
IsNotFound = true,
};
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
private readonly List<string> _timedOut = new();
public List<string> Requests { get; } = new();
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
public Action<string>? OnRequest { get; set; }
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
{
_scripted.Add((urlSubstring, status, body));
return this;
}
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
{
_timedOut.Add(urlSubstring);
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
OnRequest?.Invoke(url);
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
{
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
}
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
if (match.Equals(default((string, HttpStatusCode, string))))
{
@@ -76,6 +76,34 @@ public class GraphWireParityTests
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
}
[Fact]
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
{
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
// answer within the limits keeps the shapes pinned above.
var payload = new Dictionary<string, string?>
{
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
};
AssertParity(
new { refs = payload, timedOut = true },
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
}
[Fact]
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
{
var options = ApiJsonOptions.Create();
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
Assert.True(followUp!.Retry);
Assert.Null(first!.Retry);
}
[Fact]
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
{
@@ -0,0 +1,116 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
private static void StoreRows(Harness h)
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
private static int Reads(Harness h, string tenantId)
=> h.ConfigRepo.Invocations.Count(i =>
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
[Fact]
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
h.ConfigRepo.Invocations.Clear();
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
Assert.Equal(1, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
{
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
var h = BuildHarness();
StoreRows(h);
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
Assert.Equal(1, Reads(h, TenantA));
Assert.Equal(1, Reads(h, TenantB));
}
[Fact]
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync(
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Equal(0, Reads(h, TenantB));
}
[Fact]
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
{
var h = BuildHarness();
StoreRows(h);
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Empty(h.ConfigRepo.Invocations);
}
[Fact]
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
}
}
@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
/// answer with the tenant's row or with an error — never with the synthesized default the plain
/// cached read hands out when storage fails.
/// </summary>
public sealed class PortalConfigReadTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var service = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return (service, store);
}
private static TenantConfiguration Row(string manufacturers)
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ManufacturerWhitelist = manufacturers;
return row;
}
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
{
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
}
[Fact]
public async Task Config_answers_with_the_tenants_row()
{
var (service, _) = Service(Row("Dell*"));
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Dell*", Manufacturers(response));
}
[Fact]
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
{
var (service, store) = Service(null);
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(store.Row);
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
}
[Fact]
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
// The read fails before the homing service or the enrollment counter is asked.
var function = new GetTenantFeatureFlagsFunction(
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
@@ -0,0 +1,207 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
/// instance that served it. These tests model two instances (two services, each with its own cache)
/// over one stored row and pin the two ways the stored row reaches the other instance without
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
/// before a refusal.
/// </summary>
public sealed class TenantConfigReadAfterWriteTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
private sealed record Instances(
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
{
var row = TenantConfiguration.CreateDefault(TenantId);
seed?.Invoke(row);
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var clock = new TestTimeProvider(Start);
TenantConfigurationService Instance() => new(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
ManagedTenantProIndex.None, clock);
return new Instances(Instance(), Instance(), store, repo, clock);
}
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
=> instance.UpdateAsync(TenantId, row =>
{
row.ManufacturerWhitelist = manufacturers;
return true;
}, "test", "hardware");
// ---------------------------------------------------------------- the hazard itself
[Fact]
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
await AllowOnly(s.A, "Dell*");
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
// ---------------------------------------------------------------- RefreshFromStoreAsync
[Fact]
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
Assert.True(exists);
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
}
[Fact]
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
{
var s = TwoInstances();
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
await AllowOnly(s.A, "Dell*");
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
await service.RefreshFromStoreAsync(TenantId);
Assert.Null(store.Row);
Assert.Empty(store.Writes);
}
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
[Fact]
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*,Contoso");
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
}
[Fact]
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
{
var s = TwoInstances();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
}
[Fact]
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
{
var s = TwoInstances();
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
}
// ---------------------------------------------------------------- the bootstrap feature switch
[Fact]
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
await s.A.UpdateAsync(TenantId, row =>
{
row.BootstrapTokenEnabled = true;
return true;
}, "test", "bootstrap");
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
}
[Fact]
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
{
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
{
var s = TwoInstances();
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
}
}
@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
/// configuration for a few minutes, because a write clears only the cache of the instance that
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
/// <c>config/</c>, or is listed here with the reason it needs no hint.
/// </summary>
public class TenantConfigWriteRouteGuardTests
{
private static readonly Regex TenantConfigWrite = new(
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
RegexOptions.Compiled);
private static readonly Regex WriteRoute = new(
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
["Functions/Admin/TenantOffboardFunction.cs"] =
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
};
private static string RepoRoot()
{
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;
}
[Fact]
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
{
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var portalList = File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
Assert.Contains("\"/api/config/\"", portalList);
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
{
var source = File.ReadAllText(path);
if (!TenantConfigWrite.IsMatch(source))
continue;
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
seen.Add(rel);
if (Baseline.TryGetValue(rel, out var entry))
{
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
continue;
}
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
{
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
continue;
}
foreach (Match route in WriteRoute.Matches(source))
{
var value = route.Groups["route"].Value;
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
offenders.Add($"{rel}: write route '{value}' is not under config/");
}
}
Assert.True(offenders.Count == 0,
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
+ string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
}
}
@@ -71,8 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -83,8 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var registration = request.Registration;
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, registration.TenantId))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
@@ -72,8 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -53,8 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var request = read.Value!;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -2,6 +2,7 @@ using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -94,8 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
@@ -42,8 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var tenantId = requestCtx.TargetTenantId;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
// Return empty list instead of error — feature simply not visible
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
@@ -47,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var userIdentifier = requestCtx.UserPrincipalName;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -75,8 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await req.NotFoundAsync("Code not found, expired, or revoked");
@@ -36,7 +36,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
_logger.LogInformation($"GetTenantConfiguration: {requestCtx.TargetTenantId} by user {userIdentifier}");
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default: this answer is the baseline
// of the settings editor and of every read-modify-write in the portal, and the plain
// cached read answers a storage failure with defaults. A tenant without a row still
// gets its default row.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Only a caller with WRITE authority over THIS target tenant sees the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
@@ -47,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
///
/// Adding a field here is a deliberate decision that the field is non-sensitive: it must not
/// expose webhook URLs, SAS tokens, admin allowlists, or any other admin-only data. The full
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, gated
/// to TenantAdminOrGA.
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, which
/// redacts its secrets for every caller without write authority over the tenant.
/// </summary>
[Function("GetTenantFeatureFlags")]
public async Task<HttpResponseData> Run(
@@ -60,7 +60,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default (see GetTenantConfiguration):
// the flags decide banners and gates, and a failed answer is not cached by the portal.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -88,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
request.Reason,
callerTier);
StampUnchangedFields(req, request.Fields.Keys, outcome);
return await WriteOutcome(req, outcome);
}
catch (Exception ex)
@@ -96,6 +100,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>
/// Counts the sent fields the stored row already held onto the request row. The portal sends
/// only what its user changed, so such a field means the page showed an outdated configuration.
/// </summary>
private static void StampUnchangedFields(HttpRequestData req, IEnumerable<string> sentFields, PatchOutcome outcome)
{
var unchanged = UnchangedFieldCount(sentFields, outcome);
if (unchanged > 0)
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigPatchUnchangedKey, unchanged.ToString(CultureInfo.InvariantCulture));
}
/// <summary>Sent fields (wire names) a successful patch did not change; zero for a failed patch.</summary>
internal static int UnchangedFieldCount(IEnumerable<string> sentFields, PatchOutcome outcome)
{
if (!outcome.Success)
return 0;
var applied = new HashSet<string>(outcome.AppliedFields, StringComparer.OrdinalIgnoreCase);
return sentFields.Count(field => !applied.Contains(field));
}
/// <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.
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
@@ -77,18 +78,22 @@ public class GetScriptDisplayNamesFunction
var distinctRefs = parsedRefs.Distinct().ToList();
var resolved = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var lookup = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var payload = new Dictionary<string, string?>(distinctRefs.Count);
foreach (var r in distinctRefs)
{
payload[r.ToString()] = resolved.TryGetValue(r, out var name) ? name : null;
payload[r.ToString()] = lookup.Names.TryGetValue(r, out var name) ? name : null;
}
var marker = RequestRowMarkers.ScriptNameLookup.For(lookup.TimedOut, isRetry: body.Retry == true);
if (marker != null) RequestRowMarkers.Stamp(req, RequestRowMarkers.ScriptNameLookupKey, marker);
return await req.OkAsync(new GetScriptDisplayNamesResponse
{
Refs = payload,
Malformed = malformed.Count > 0 ? malformed : null,
TimedOut = lookup.TimedOut ? true : null,
});
}
catch (Exception ex)
@@ -13,9 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <list type="bullet">
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
/// renamed Intune script eventually surfaces under its new name.</item>
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
/// once the cache is gone the resolver will lazily re-check.</item>
/// <item>Negative (NotFound) rows live just as long (<see cref="NotFoundTtl"/>): they
/// suppress repeated 404 fetches for an ID Graph does not know. A list pull that
/// contains the ID after all overwrites the row.</item>
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
/// </list>
@@ -27,8 +27,11 @@ public class ScriptNameCacheCleanupFunction
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
/// <summary>
/// Same lifetime as a name. A shorter one asks Graph again for every unknown ID on the
/// first view after each cleanup run.
/// </summary>
public static readonly TimeSpan NotFoundTtl = PositiveTtl;
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
private const string Cron = "0 0 3 * * *";
@@ -434,10 +434,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
RegisteredAt = DateTime.UtcNow
});
/// <summary>
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
/// the agent treats register and telemetry uniformly when a session is being deleted.
/// </summary>
/// <summary>
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
@@ -492,6 +488,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
});
/// <summary>
/// 410 Gone for the cascade-delete guard: a cascade owns the session, or its tombstone is
/// still fresh. Same status as the telemetry ingest. The agent ends monitoring on it and
/// removes itself; a WhiteGlove Part 2 start registers under a new session id instead.
/// </summary>
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
{
@@ -0,0 +1,24 @@
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// The feature switch in front of every bootstrap route (Pro plan, or the per-tenant flag). A "no"
/// is confirmed against the stored row first (<see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>):
/// the cached copy can predate the plan change that enabled the feature, and a tenant that has just
/// become Pro creates and uses its first code at once. False for an unknown tenant.
/// </summary>
internal static class BootstrapFeatureGate
{
public static async Task<bool> IsEnabledAsync(TenantConfigurationService configService, string tenantId)
{
var (config, exists) = await configService.TryGetConfigurationAsync(tenantId);
if (!exists)
return false;
if (TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
return true;
var stored = await configService.ConfirmBeforeRefusalAsync(tenantId);
return stored != null && TenantEntitlementService.IsBootstrapEnabled(stored, DateTime.UtcNow);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// The <c>X-Tenant-Config: stored</c> request header. A client sends it for a few minutes after it
/// wrote a tenant configuration, because a write clears only the cache of the instance that served
/// it: the instance that answers reads the stored row first. Caller-supplied, so it only ever costs
/// the caller's own request one read — it grants nothing.
/// </summary>
internal static class StoredConfigHeader
{
public const string HeaderName = "X-Tenant-Config";
public const string Stored = "stored";
/// <summary>True when the request asks for the stored row instead of this instance's cached copy.</summary>
public static bool IsSet(HttpContext? httpContext)
=> httpContext != null && string.Equals(
httpContext.Request.Headers[HeaderName].FirstOrDefault(), Stored, StringComparison.OrdinalIgnoreCase);
}
@@ -96,12 +96,18 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
// A signed-in caller that has just written a tenant configuration asks for the stored row
// (StoredConfigHeader); the request row counts how often that happens.
var readStoredConfig = principal != null && StoredConfigHeader.IsSet(httpContext);
if (readStoredConfig)
context.Items[RequestRowMarkers.ConfigRefreshKey] = RequestRowMarkers.ConfigRefreshHint;
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
PolicyResult result;
try
{
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs, readStoredConfig);
}
catch (Exception ex)
{
@@ -224,10 +230,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
/// <paramref name="readStoredConfig"/> (<see cref="StoredConfigHeader"/>) reads the stored configuration of
/// the acting and the target tenant into this instance's cache first, so this decision and the function
/// behind it see what the caller has just written through another instance.
/// </summary>
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
bool isMcpClient = false, ViewAsTarget? viewAs = null)
bool isMcpClient = false, ViewAsTarget? viewAs = null, bool readStoredConfig = false)
{
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
@@ -255,6 +264,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
viewing = viewAs;
}
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
// Before the roles and the suspension gate below, which read this tenant's configuration too.
readStoredConfig = readStoredConfig && principal != null;
if (readStoredConfig)
await _tenantConfigService.RefreshFromStoreAsync(actingTenantId);
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
@@ -327,6 +342,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
}
// The target of an admitted cross-tenant call (Global Admin, delegated reader) likewise; only
// past the cross-tenant check, so nobody makes this instance read a tenant it may not reach.
if (readStoredConfig && !string.Equals(targetTenantId, actingTenantId, StringComparison.OrdinalIgnoreCase))
await _tenantConfigService.RefreshFromStoreAsync(targetTenantId);
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
@@ -231,6 +231,20 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
requestTelemetry.Properties[key] = text;
}
// TENANT-CONFIG FRESHNESS — the stored row was read instead of this instance's cached copy
// (a caller's own write, a refusal about to happen), or a field patch found values the
// client believed it was changing.
foreach (var key in RequestRowMarkers.ConfigFreshnessKeys)
{
if (context.Items.TryGetValue(key, out var value) && value is string text)
requestTelemetry.Properties[key] = text;
}
// SCRIPT-NAME LOOKUP — set by GetScriptDisplayNames when a lookup ran into a time limit
// or is the portal's follow-up to one.
if (context.Items.TryGetValue(RequestRowMarkers.ScriptNameLookupKey, out var scriptNameLookup) && scriptNameLookup is string scriptNameLookupOutcome)
requestTelemetry.Properties[RequestRowMarkers.ScriptNameLookupKey] = scriptNameLookupOutcome;
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.IdentityTenantId(),
@@ -57,6 +57,64 @@ namespace AutopilotMonitor.Functions.Security
public const string SessionUnknown = "session_unknown";
}
/// <summary>
/// The agent gate read the stored tenant configuration before refusing a request the cached
/// copy would have refused (<c>TenantConfigurationService.ConfirmBeforeRefusalAsync</c>).
/// Absent on requests that pass on the cached copy and within the confirm interval.
/// </summary>
public const string ConfigConfirmKey = "ConfigConfirm";
/// <summary>Closed set of <see cref="ConfigConfirmKey"/> values.</summary>
public static class ConfigConfirm
{
/// <summary>The stored row no longer refuses: another instance's write had outdated the copy.</summary>
public const string Averted = "averted";
/// <summary>The stored row refuses as well.</summary>
public const string Confirmed = "confirmed";
/// <summary>The stored row switches on a validation the chain had not asked; the chain ran again.</summary>
public const string Rerun = "rerun";
}
/// <summary>
/// A signed-in request asked for the stored tenant configuration (<c>StoredConfigHeader</c>):
/// its caller wrote the configuration within the last minutes.
/// </summary>
public const string ConfigRefreshKey = "ConfigRefresh";
/// <summary>The one <see cref="ConfigRefreshKey"/> value.</summary>
public const string ConfigRefreshHint = "hint";
/// <summary>
/// On a tenant-configuration field patch: how many of the sent fields the stored row already
/// held. The client believed they differed, so it was showing an outdated configuration.
/// Absent when every sent field changed.
/// </summary>
public const string ConfigPatchUnchangedKey = "ConfigPatchUnchanged";
/// <summary>
/// On a script display-name lookup: one of <see cref="ScriptNameLookup"/>. Absent on a
/// first lookup that stayed within its time limits, which is the normal case.
/// </summary>
public const string ScriptNameLookupKey = "ScriptNameLookup";
/// <summary>Closed set of <see cref="ScriptNameLookupKey"/> values.</summary>
public static class ScriptNameLookup
{
/// <summary>A first lookup ran into a time limit; the portal asks again once.</summary>
public const string TimedOut = "timedOut";
/// <summary>The portal's follow-up lookup, answered within the limits.</summary>
public const string Retry = "retry";
/// <summary>The follow-up ran into a time limit too: the names stay missing until a reload.</summary>
public const string RetryTimedOut = "retryTimedOut";
/// <summary>The value for one lookup, or null when the row gets no marker.</summary>
public static string? For(bool timedOut, bool isRetry)
{
if (isRetry) return timedOut ? RetryTimedOut : Retry;
return timedOut ? TimedOut : null;
}
}
/// <summary>
/// Outcome of the Intune device lookup behind the client certificate
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
@@ -90,6 +148,12 @@ namespace AutopilotMonitor.Functions.Security
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
};
/// <summary>The tenant-configuration freshness keys; copied onto the request row like the ones above.</summary>
public static readonly string[] ConfigFreshnessKeys =
{
ConfigConfirmKey, ConfigRefreshKey, ConfigPatchUnchangedKey,
};
/// <summary>
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
@@ -139,7 +139,48 @@ namespace AutopilotMonitor.Functions.Security
/// add-on permission counts toward the add-on notice.
/// </param>
/// <returns>Security validation result</returns>
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
public Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
=> ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: false);
/// <summary>
/// Whether a gate the tenant configuration alone decides would refuse this request: the
/// suspension, the bootstrap feature for a token request, "no validation switched on", the
/// hardware whitelist. Mirrors those gates of the validation below in their order; pure, logs
/// nothing. A gate added there that the configuration decides belongs here too, or it keeps
/// refusing on an outdated copy.
/// </summary>
internal bool RefusesByConfiguration(TenantConfiguration config, HttpRequestData req)
{
if (config.IsCurrentlyDisabled())
return true;
// A request the bootstrap token admits is decided by the feature switch alone.
if (GetBootstrapToken(req) != null && _bootstrapSessionService != null)
return !Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow);
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
return true;
return !HardwareWhitelistValidator.ValidateHardware(
req.Headers.Contains("X-Device-Manufacturer") ? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault() : null,
req.Headers.Contains("X-Device-Model") ? req.Headers.GetValues("X-Device-Model").FirstOrDefault() : null,
config.GetManufacturerWhitelist(),
config.GetModelWhitelist()).IsValid;
}
/// <summary>
/// Whether <paramref name="stored"/> switches on a device validation the chain did not ask
/// while it ran on <paramref name="used"/>.
/// </summary>
internal static bool SwitchesOnUnaskedValidation(TenantConfiguration used, TenantConfiguration stored)
=> (stored.ValidateAutopilotDevice && !used.ValidateAutopilotDevice)
|| (stored.ValidateCorporateIdentifier && !used.ValidateCorporateIdentifier)
|| (stored.ValidateDeviceAssociation && !used.ValidateDeviceAssociation)
|| (stored.ValidateCloudPcDevice && !used.ValidateCloudPcDevice)
|| (stored.ValidateIntuneDeviceBinding && !used.ValidateIntuneDeviceBinding);
private async Task<SecurityValidationResult> ValidateRequestAsync(
HttpRequestData req, string tenantId, string? sessionId, bool selfReportedCloudPc, bool configConfirmed)
{
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
@@ -156,6 +197,23 @@ namespace AutopilotMonitor.Functions.Security
};
}
// A refusal the configuration decides is never taken from a copy another instance's write
// has outdated: the stored row is read first, before any gate has run or logged. Requests
// the cached copy admits read nothing here. An unknown tenant needs no confirmation —
// nothing is cached for a missing row, so that answer came from storage.
if (!configConfirmed && RefusesByConfiguration(config, req))
{
configConfirmed = true;
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
if (stored != null)
{
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RefusesByConfiguration(stored, req)
? RequestRowMarkers.ConfigConfirm.Confirmed
: RequestRowMarkers.ConfigConfirm.Averted);
config = stored;
}
}
if (config.IsCurrentlyDisabled())
{
_logger.LogWarning("Rejected agent request: suspended tenant {TenantId} (reason: {Reason})",
@@ -592,6 +650,21 @@ namespace AutopilotMonitor.Functions.Security
};
}
// Definitive: no switched-on validation knows the device. When the stored row switches
// on one this pass has not asked (another instance's write), the chain runs once more
// on it — the read above put that row into this instance's cache.
if (!configConfirmed)
{
var stored = await _configService.ConfirmBeforeRefusalAsync(tenantId);
if (stored != null && SwitchesOnUnaskedValidation(config, stored))
{
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Rerun);
return await ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc, configConfirmed: true);
}
if (stored != null)
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigConfirmKey, RequestRowMarkers.ConfigConfirm.Confirmed);
}
LogRequestRejection("device", tenantId, req, sessionId,
extraReason: deviceValidationError,
thumbprint: certValidation.Thumbprint);
@@ -11,15 +11,22 @@ namespace AutopilotMonitor.Functions.Services.GraphResolution;
/// script recently?), the Graph fetch (list-full-pull or per-ID fallback), and the negative-
/// cache write for 404s.
/// <para>
/// Returns a dictionary where the value for any unresolved ref is <c>null</c>. Never throws
/// to the caller — Graph timeouts / 429s / 5xx degrade to "no name" so the UI can still
/// render IDs. Cache failures behave the same way.
/// The value for any unresolved ref is <c>null</c>. Never throws to the caller — Graph
/// timeouts / 429s / 5xx degrade to "no name" so the UI can still render IDs. Cache failures
/// behave the same way.
/// </para>
/// </summary>
public interface IScriptDisplayNameResolver
{
Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
Task<ScriptDisplayNameLookup> ResolveAsync(
string tenantId,
IReadOnlyCollection<ScriptRef> refs,
CancellationToken ct = default);
}
/// <summary>
/// One lookup's outcome. <see cref="TimedOut"/> is true when a time limit of the resolver ended
/// the lookup before every ref had an answer, so asking again can return more names. A failed
/// Graph call leaves it false: asking again at once would only add to a throttle or an outage.
/// </summary>
public sealed record ScriptDisplayNameLookup(IReadOnlyDictionary<ScriptRef, string?> Names, bool TimedOut);
@@ -1,6 +1,7 @@
using AutopilotMonitor.Shared;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net;
@@ -33,11 +34,11 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
internal static readonly TimeSpan TotalBudget = TimeSpan.FromSeconds(5);
/// <summary>
/// Tight cap on the token-acquire path specifically. The default
/// <see cref="GraphTokenService"/> retries 5+15+30 s with no cancellation in older code paths;
/// for this optional feature we want a hard wall well below <see cref="TotalBudget"/>.
/// Cap on the token-acquire path, whose retry chain (5+15+30 s) would otherwise outlast
/// <see cref="TotalBudget"/>. The first mint on a cold instance often needs more than
/// 1.5 s; a lower value drops every name on the first lookup after an idle period.
/// </summary>
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromMilliseconds(1500);
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromSeconds(3);
private const string GraphBetaBase = Constants.GraphBaseUrl + "/beta";
private const int PageSize = 100;
@@ -48,6 +49,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
private readonly ILogger<ScriptDisplayNameResolver> _logger;
private readonly TelemetryClient _telemetry;
private readonly TimeProvider _time;
private readonly TimeSpan _tokenAcquireBudget;
public ScriptDisplayNameResolver(
IGraphFeatureDetector detector,
@@ -66,7 +68,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
IHttpClientFactory httpClientFactory,
ILogger<ScriptDisplayNameResolver> logger,
TelemetryClient telemetry,
TimeProvider time)
TimeProvider time,
TimeSpan? tokenAcquireBudget = null)
{
_detector = detector;
_cache = cache;
@@ -74,38 +77,69 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
_logger = logger;
_telemetry = telemetry;
_time = time;
_tokenAcquireBudget = tokenAcquireBudget ?? TokenAcquireBudget;
}
public async Task<IReadOnlyDictionary<ScriptRef, string?>> ResolveAsync(
// Reasons in the "lookup incomplete" line. The three time limits also set
// ScriptDisplayNameLookup.TimedOut, which tells the caller that asking again can help.
private const string TokenTimeout = "TokenTimeout";
private const string TokenError = "TokenError";
private const string LookupTimeout = "LookupTimeout";
private const string LookupError = "LookupError";
public async Task<ScriptDisplayNameLookup> ResolveAsync(
string tenantId,
IReadOnlyCollection<ScriptRef> refs,
CancellationToken ct = default)
{
var result = new Dictionary<ScriptRef, string?>();
if (refs == null || refs.Count == 0) return result;
if (refs == null || refs.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
foreach (var r in refs) result[r] = null;
if (string.IsNullOrWhiteSpace(tenantId)) return result;
if (string.IsNullOrWhiteSpace(tenantId)) return new ScriptDisplayNameLookup(result, TimedOut: false);
// Total budget starts BEFORE the detector call. A slow Graph token-acquire used to
// be able to burn ~50 s before this method even decided whether to proceed; the
// linked CTS + the detector's CT support cap that to TokenAcquireBudget.
var started = Stopwatch.GetTimestamp();
using var budgetCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
budgetCts.CancelAfter(TotalBudget);
var budget = budgetCts.Token;
// 1. Permission gate — with a tight sub-budget so the optional feature can't hang
// waiting for a slow Azure AD response on tenants with consent-propagation delay.
GraphTenantTokenContext? ctx;
GraphTenantTokenContext? ctx = null;
string? tokenProblem = null;
using (var tokenCts = CancellationTokenSource.CreateLinkedTokenSource(budget))
{
tokenCts.CancelAfter(TokenAcquireBudget);
ctx = await SafeAsync(_detector.TryGetTokenContextAsync(tenantId, tokenCts.Token)).ConfigureAwait(false);
tokenCts.CancelAfter(_tokenAcquireBudget);
try
{
ctx = await _detector.TryGetTokenContextAsync(tenantId, tokenCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Counted below as a timeout when it was this class's cap that fired.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Script-name resolver: token context failed for tenant={Tenant}", tenantId);
tokenProblem = TokenError;
}
if (ctx == null && tokenProblem == null && tokenCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
tokenProblem = TokenTimeout;
}
}
if (ctx == null)
{
// No token means no cache read either, so resolved and cacheHits are 0 by construction.
if (tokenProblem != null) LogIncomplete(tenantId, new[] { tokenProblem }, refs.Count, resolved: 0, cacheHits: 0, started);
return new ScriptDisplayNameLookup(result, TimedOut: tokenProblem == TokenTimeout);
}
if (ctx == null) return result;
if (!ctx.GrantedRoles.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll))
{
return result;
return new ScriptDisplayNameLookup(result, TimedOut: false);
}
// 2. Cache lookup for all refs.
@@ -131,13 +165,16 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
var triggeredFullPull = false;
var missing = refs.Where(r => !cached.ContainsKey(r)).ToList();
if (missing.Count == 0) return result;
if (missing.Count == 0) return new ScriptDisplayNameLookup(result, TimedOut: false);
// 3. Decide per kind: list-full-pull when meta stale, else per-ID fallback.
// Track kinds whose full-pull hit a TRANSIENT failure (429/503/network/timeout).
// Falling back to per-ID for those kinds in the same call would just amplify the
// throttling Graph is trying to apply, so we suppress per-ID for that kind here.
var skipPerIdKinds = new HashSet<ScriptKind>();
// Why refs may end without an answer; see LogIncomplete.
var incomplete = new List<string>();
var timedOut = false;
foreach (var kind in missing.Select(r => r.Kind).Distinct())
{
@@ -172,6 +209,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
{
_logger.LogInformation("Script-name resolver: full-pull cancelled for tenant={Tenant} kind={Kind}", tenantId, kind);
skipPerIdKinds.Add(kind);
incomplete.Add($"ListTimeout:{kind}");
timedOut = true;
}
catch (HttpRequestException ex)
{
@@ -181,6 +220,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
"Script-name resolver: full-pull transient for tenant={Tenant} kind={Kind} — skipping per-ID fallback for this kind",
tenantId, kind);
skipPerIdKinds.Add(kind);
incomplete.Add($"ListError:{kind}");
}
catch (Exception ex)
{
@@ -192,12 +232,19 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
}
// 4. Per-ID fallback for whatever the full-pull (or its absence) did not produce.
// This step stays after a successful list too: the list leaves out some scripts
// that a single GET returns, so "not in the list" never means "not found".
var stillMissing = refs
.Where(r => result[r] == null && !cached!.ContainsKey(r) && !skipPerIdKinds.Contains(r.Kind))
.ToList();
foreach (var r in stillMissing)
{
if (budget.IsCancellationRequested) break;
if (budget.IsCancellationRequested)
{
incomplete.Add(LookupTimeout);
timedOut = true;
break;
}
try
{
var (displayName, notFound) = await FetchSingleAsync(r.Kind, r.Id, ctx.AccessToken, budget).ConfigureAwait(false);
@@ -216,6 +263,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
catch (OperationCanceledException)
{
_logger.LogInformation("Script-name resolver: per-ID fetch cancelled for tenant={Tenant} ref={Ref}", tenantId, r);
incomplete.Add(LookupTimeout);
timedOut = true;
break;
}
catch (HttpRequestException ex)
@@ -225,28 +274,48 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
_logger.LogWarning(ex,
"Script-name resolver: per-ID transient for tenant={Tenant} ref={Ref} — aborting remaining per-ID fallbacks",
tenantId, r);
incomplete.Add(LookupError);
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Script-name resolver: per-ID fetch failed for tenant={Tenant} ref={Ref}", tenantId, r);
if (!incomplete.Contains(LookupError)) incomplete.Add(LookupError);
}
}
var resolved = result.Values.Count(v => v != null);
if (incomplete.Count > 0 && !ct.IsCancellationRequested)
{
LogIncomplete(tenantId, incomplete, refs.Count, resolved, cacheHits, started);
}
// Usage telemetry — fires only when the optional permission was active AND the
// resolver actually did work. Volume = once per session-view that has scripts,
// which is a meaningful "tenant is using this feature" pulse.
EmitResolved(tenantId, refs.Count, result, cacheHits, triggeredFullPull);
EmitResolved(tenantId, refs.Count, resolved, cacheHits, triggeredFullPull);
return result;
return new ScriptDisplayNameLookup(result, timedOut);
}
private void EmitResolved(string tenantId, int refsRequested, Dictionary<ScriptRef, string?> result, int cacheHits, bool triggeredFullPull)
/// <summary>
/// One Warning per lookup that ended without an answer for every ref because a limit of
/// this class fired or Graph failed. Worker logs below Warning are not collected, and the
/// health marker reads the message prefix and the <c>reasons=</c> field: keep both stable.
/// </summary>
private void LogIncomplete(
string tenantId, IReadOnlyCollection<string> reasons, int requested, int resolved, int cacheHits, long started)
{
_logger.LogWarning(
"Script-name lookup incomplete: tenant={Tenant} reasons={Reasons} requested={Requested} resolved={Resolved} cacheHits={CacheHits} elapsedMs={ElapsedMs}",
tenantId, string.Join(",", reasons), requested, resolved, cacheHits,
(long)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
private void EmitResolved(string tenantId, int refsRequested, int resolved, int cacheHits, bool triggeredFullPull)
{
try
{
var resolved = 0;
foreach (var v in result.Values) { if (v != null) resolved++; }
_telemetry.TrackEvent("ScriptDisplayNamesResolved", new Dictionary<string, string>
{
["TenantId"] = tenantId,
@@ -314,6 +383,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
url = nextUrl;
}
// A budget that ends between two pages leaves the loop with pages still to read. That
// list is incomplete and must not be stored, or the kind counts as fetched for a week.
if (!string.IsNullOrEmpty(url)) ct.ThrowIfCancellationRequested();
if (entries.Count > 0)
{
try
@@ -29,25 +29,47 @@ namespace AutopilotMonitor.Functions.Services
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
/// <summary>
/// How often <see cref="ConfirmBeforeRefusalAsync"/> reads the stored row per tenant on this
/// instance: a burst of refused requests costs one read per interval, not one each.
/// </summary>
internal static readonly TimeSpan RefusalConfirmInterval = TimeSpan.FromSeconds(15);
// Concurrent misses for one tenant (cold start, after invalidation) share one repository
// read instead of each issuing their own (audit 2026-09-05 F12).
private readonly ConcurrentDictionary<string, Lazy<Task<TenantConfiguration>>> _inFlight = new();
// When this instance last read the stored row before a refusal, per tenant.
private readonly ConcurrentDictionary<string, DateTimeOffset> _refusalConfirmedAt = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _time;
public TenantConfigurationService(
IConfigRepository configRepo,
ILogger<TenantConfigurationService> logger,
IMemoryCache cache,
ManagedTenantProIndex proIndex)
: this(configRepo, logger, cache, proIndex, TimeProvider.System)
{
}
/// <summary>Test seam — a clock the test moves across <see cref="RefusalConfirmInterval"/>.</summary>
internal TenantConfigurationService(
IConfigRepository configRepo,
ILogger<TenantConfigurationService> logger,
IMemoryCache cache,
ManagedTenantProIndex proIndex,
TimeProvider time)
{
_configRepo = configRepo;
_logger = logger;
_cache = cache;
_proIndex = proIndex;
_time = time;
}
/// <summary>
/// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
/// resolves the four-argument constructor (the longest satisfiable one).
/// resolves the four-parameter constructor (the longest public one it can satisfy).
/// </summary>
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
: this(configRepo, logger, cache, ManagedTenantProIndex.None)
@@ -320,12 +342,11 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Cache-BYPASSING read (refreshes this instance's cache on hit); null when no row exists,
/// never auto-creates. For read-modify-write side-effect writers that persist the WHOLE
/// entity (e.g. AuthFunction's LastAuthClientId tracking): a cached view can be up to
/// 5 minutes stale on OTHER instances, and blindly saving it would silently rewind any
/// field another writer just changed — most critically HomedAppClientId right after a
/// consent-driven app-homing flip (the exact login that follows a flip is the one that
/// carries a changed audience and triggers the tracking write).
/// never auto-creates. A write clears only the cache of the instance that served it, so a
/// cached view can be up to 5 minutes stale on every OTHER instance. Callers that must not
/// act on such a view read here: a verdict on the tenant's current state (activation), and
/// the two wrappers below — <see cref="RefreshFromStoreAsync"/> for a request that follows
/// its caller's own write, <see cref="ConfirmBeforeRefusalAsync"/> before a refusal.
/// </summary>
public virtual async Task<TenantConfiguration?> GetConfigurationFreshAsync(string tenantId)
{
@@ -339,6 +360,75 @@ namespace AutopilotMonitor.Functions.Services
return await ProjectAsync(config);
}
/// <summary>
/// Reads the stored row into this instance's cache, so that everything the current request
/// reads afterwards sees it. For a request whose caller has just written the configuration
/// through another instance. Never throws: on a storage failure the cached copy stays in use
/// and the request goes on.
/// </summary>
public virtual async Task RefreshFromStoreAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return;
try
{
await GetConfigurationFreshAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Stored configuration of tenant {TenantId} could not be re-read; the cached copy stays in use", tenantId);
}
}
/// <summary>
/// The stored row for a caller about to refuse a request because of the configuration, or
/// null when the cached answer stands. A refusal is never decided on a copy another
/// instance's write has outdated: an admin allows a manufacturer or switches a validation on,
/// and the next device must not be turned away for up to 5 minutes. Requests that pass never
/// come here, so the hot path keeps reading the cache.
/// <para>
/// Null within <see cref="RefusalConfirmInterval"/> of the last read for this tenant (by then
/// this instance's cache holds that read), when no row exists, and when the read fails — the
/// caller then refuses as the cached copy says.
/// </para>
/// </summary>
public virtual async Task<TenantConfiguration?> ConfirmBeforeRefusalAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return null;
// The factories can run more than once under contention; each run decides `claimed` anew.
var now = _time.GetUtcNow();
var claimed = false;
_refusalConfirmedAt.AddOrUpdate(
tenantId,
_ =>
{
claimed = true;
return now;
},
(_, last) =>
{
claimed = now - last >= RefusalConfirmInterval;
return claimed ? now : last;
});
if (!claimed)
return null;
try
{
return await GetConfigurationFreshAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Stored configuration of tenant {TenantId} could not be read before a refusal; the cached copy decides", tenantId);
return null;
}
}
/// <summary>
/// Returns (config, exists). exists=false when no row was found — does NOT auto-create.
/// Use for agent security gates where unknown tenants must be rejected: storage errors are
@@ -59,6 +59,13 @@ describe('get_tenant_config — forced redaction pin', () => {
const path = apiFetchMock.mock.calls[0][0] as string;
expect(path).toBe(`/api/config/${TENANT}?view=redacted`);
});
it('asks for the stored row — the read that checks update_tenant_config must not come from an instance cache', async () => {
apiFetchMock.mockResolvedValueOnce({ tenantId: TENANT });
await captureToolHandlers(true, true).get_tenant_config({ tenantId: TENANT });
expect(apiFetchMock.mock.calls[0][1]).toEqual({ headers: { 'X-Tenant-Config': 'stored' } });
});
});
describe('get_tenant_config_schema — request shape', () => {
@@ -2445,6 +2445,8 @@ export interface GetScriptDisplayNamesResponse {
refs: Record<string, string | null>;
/** Ref tokens from the request that failed to parse, or null when there were none — the key is omitted when null (the empty-body and empty-refs early-exit sites never wrote this key at all; they leave it null). */
malformed?: string[];
/** True when the lookup ran into one of its time limits before every ref had an answer, so asking again can return more names. Null otherwise — the key is omitted. A failed Graph call does not set it. */
timedOut?: boolean;
}
/** Response of GET sessions/{sessionId}/annotations: the annotation lanes visible to the caller plus the server-computed list of lanes the caller may write. */
@@ -4755,6 +4757,8 @@ export interface SaveNotificationEmailRequest {
/** Body of POST tenants/{tenantId}/scripts/display-names: Graph script references to resolve. */
export interface ScriptDisplayNamesRequest {
refs?: string[];
/** True on the one follow-up lookup a client sends after a response with timedOut. The lookup itself runs the same; the flag only labels the request. */
retry?: boolean | null;
}
/** SearchSessions envelope. Sessions carries full SessionSummary items, or dictionary projections of them when the caller passed a fields= subset. */
@@ -1818,7 +1818,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
// view=redacted is load-bearing: without it the backend serves a GA the
// clear-text secrets, which must never enter model context (pinned by
// security-guards tests).
const data = await apiFetch<TenantConfiguration>(`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`);
// X-Tenant-Config asks for the stored row: the backend caches a configuration per
// instance, and this read is how a caller checks what update_tenant_config just wrote.
const data = await apiFetch<TenantConfiguration>(
`/api/config/${encodeURIComponent(args.tenantId)}?view=redacted`,
{ headers: { 'X-Tenant-Config': 'stored' } });
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_tenant_config', args, error);
@@ -47,6 +47,14 @@ namespace AutopilotMonitor.Shared.Models
/// channel is exactly what was refused.
/// </summary>
TelemetryRejectedSessionUnknown = 5,
/// <summary>
/// POST /api/agent/register-session answered 410 Gone at agent start: the session this
/// device resumes was deleted or is being deleted. The agent ends monitoring and removes
/// itself; this single report is the last thing the device says about the session. A
/// WhiteGlove Part 2 start registers a new session instead and sends no report.
/// </summary>
RegistrationRejectedSessionGone = 6,
}
/// <summary>
@@ -4,8 +4,7 @@ namespace AutopilotMonitor.Shared.Models.Graph
{
/// <summary>
/// One cached display-name row. Negative results (<see cref="IsNotFound"/> = true) live
/// for a shorter TTL than positive results so deleted/renamed Intune scripts get
/// re-resolved quickly.
/// as long as positive ones.
/// </summary>
public sealed class ScriptDisplayNameEntry
{
@@ -25,6 +25,13 @@ namespace AutopilotMonitor.Shared.Models
/// wrote this key at all; they leave it null).
/// </summary>
public IReadOnlyList<string>? Malformed { get; set; }
/// <summary>
/// True when the lookup ran into one of its time limits before every ref had an answer,
/// so asking again can return more names. Null otherwise — the key is omitted. A failed
/// Graph call does not set it.
/// </summary>
public bool? TimedOut { get; set; }
}
/// <summary>
@@ -65,5 +72,11 @@ namespace AutopilotMonitor.Shared.Models
public class ScriptDisplayNamesRequest : IApiRequest
{
public List<string>? Refs { get; set; }
/// <summary>
/// True on the one follow-up lookup a client sends after a response with
/// <c>timedOut</c>. The lookup itself runs the same; the flag only labels the request.
/// </summary>
public bool? Retry { get; set; }
}
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* The session page takes its session from `?id=`, so a link from one session to another
* keeps the route mounted. Its content must be keyed by that id: the event list is
* append-only (see mergeNewEvents), and without a remount the previous session's events
* stay in it and mix into the new session's timeline.
*/
describe("session page remounts per session", () => {
const source = readFileSync(join(__dirname, "..", "page.tsx"), "utf8");
it("keys its content by the session id from the URL", () => {
expect(source).toContain('const id = useSearchParams()?.get("id") ?? "";');
expect(source).toContain("<SessionDetailContent key={id} />");
});
it("renders the content from that one keyed place only", () => {
expect(source.match(/<SessionDetailContent\b/g)).toHaveLength(1);
});
});
@@ -63,14 +63,22 @@ import { DOCS_PATHS } from "@/lib/docsPaths";
import type { SubmitSessionReportRequest } from "@/lib/generated/wire-types.generated";
export default function SessionDetailPage() {
// useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender.
// useSearchParams() below requires a Suspense boundary for static prerender.
return (
<Suspense fallback={null}>
<SessionDetailContent />
<SessionDetailForCurrentId />
</Suspense>
);
}
function SessionDetailForCurrentId() {
// A link to another session only changes `?id=`, so this route stays mounted. The key
// gives every session its own state: without it the previous session's events stay in
// the append-only list and mix into the new session's timeline.
const id = useSearchParams()?.get("id") ?? "";
return <SessionDetailContent key={id} />;
}
function SessionDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -182,7 +190,8 @@ function SessionDetailContent() {
// Resolves Intune script display names via the optional Graph add-on permission.
// Returns an empty map when the tenant hasn't granted DeviceManagementScripts.Read.All.
// Map keys are "{Kind}:{Id}" -- the renderer uses lookupScriptDisplayName to read them.
const scriptDisplayNamesByRefKey = useScriptDisplayNames(sessionTenantId ?? tenantId, events, getAccessToken);
// The lookup waits for the full event walk, so it runs once with every script of the session.
const scriptDisplayNamesByRefKey = useScriptDisplayNames(sessionTenantId ?? tenantId, events, getAccessToken, eventsApi.initialWalkComplete);
const { analysisResults, loadingAnalysis, analysisLoaded, vulnerabilityReport, fetchAnalysisResults, fetchVulnerabilityReport, persistFailureRuleIds, stoppedRuleIds } = analysis;
const { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer } = tenantConfig;
const {
@@ -90,7 +90,7 @@ function ValidationTour() {
const { user, getAccessToken } = useAuth();
const { tenantId } = useTenant();
const {
canEditConfig, error, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
config, canEditConfig, error, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
} = useTenantConfig();
const [target, setTarget] = useState<"autopilot" | "preparation">("autopilot");
@@ -101,7 +101,8 @@ function ValidationTour() {
const anyGate = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation
|| validateCloudPcDevice || validateIntuneDeviceBinding;
const readOnly = !!user?.viewAs || !canEditConfig;
const failed = !!error && !anyGate && !autopilotConsentInProgress;
// Only with a loaded configuration: a failed load sets the same error and says nothing about the approval.
const failed = !!config && !!error && !anyGate && !autopilotConsentInProgress;
useEffect(() => {
if (anyGate && tenantId) markSetup(tenantId, { validation: true });
@@ -29,18 +29,20 @@ const ACCESS_RESTORED_MESSAGE = "Access restored. The notice on the dashboard ca
import { parseSasExpiry } from "./components/DiagnosticsSection";
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin } from "./types";
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
import type { SettingsSectionName } from "./sectionFieldMap";
import {
DEFAULT_SETTINGS_FORM,
configFromForm,
formFromConfig,
hardwareRejectionNotify,
sectionSavePayload,
sectionsWithUnsavedEdits,
settingsFormSetters,
type SectionSaveOverrides,
type SettingsForm,
type SettingsFormSetters,
} from "./lib/settingsForm";
import { changedTenantConfigFields, patchTenantConfigFields } from "@/lib/tenantConfigSave";
import { noteTenantConfigWritten } from "@/lib/storedConfigHint";
import { patchTenantConfigFields } from "@/lib/tenantConfigSave";
import { subscribeTenantConfigPatches } from "@/lib/tenantConfigPatches";
import { leaveOrAsk } from "@/lib/unsavedChanges";
import { looksLikeGuid, type MemberKind } from "@/lib/principalKeys";
@@ -309,8 +311,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("app_homing_flipped", { source });
setHomingFlipped(true);
setAppHomingFunnelActive(false);
// The flip changed appHomingFunnelActive in the feature flags the dashboard banner reads.
// The flip changed appHomingFunnelActive in the feature flags the dashboard banner reads. The
// backend wrote it during a GET, which the fetch layer does not see as a configuration write.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
noteTenantConfigWritten();
setHomingMissingRoles(null);
homingPendingRef.current = false;
setConfig(prev => (prev ? { ...prev, homedAppClientId: primaryClientId() ?? prev.homedAppClientId } : prev));
@@ -500,7 +504,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
// Resolves to null when the save went through, otherwise to the text of the failure.
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<string | null> => {
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: SectionSaveOverrides): Promise<string | null> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return "This configuration cannot be edited right now.";
@@ -526,17 +530,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setError(null);
setSuccessMessage(null);
// A gate toggle passes its new value explicitly: the state update it made before calling
// has not rendered yet, so the form still holds the previous value.
const gateOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value != null));
const updatedConfig: TenantConfiguration = { ...configFromForm(config, form), ...gateOverrides };
// Per-section PATCH: send ONLY this section's fields (plus documented write-throughs)
// that actually differ from the loaded config. The other ~90 fields never round-trip,
// so a stale read cannot revert unrelated fields (the 2026-07-31 incident class), and
// GA-only toggles a tenant admin cannot edit are simply never in the payload.
const spec: SectionFieldSpec = SECTION_FIELD_MAP[sectionName];
const patchFields = changedTenantConfigFields(config, updatedConfig, [...spec.fields, ...(spec.alsoWrites ?? [])]);
// Per-section PATCH: send ONLY what the admin changed in this section (plus documented
// write-throughs), as sectionSavePayload decides it. Untouched fields never round-trip, so a
// page loaded before somebody else's write cannot revert it (the 2026-07-31 incident class),
// and GA-only toggles a tenant admin cannot edit are simply never in the payload. A gate
// toggle passes its new value explicitly: the state update it made before calling has not
// rendered yet, so the form still holds the previous value.
const patchFields = sectionSavePayload(config, form, sectionName, overrides);
// The PATCH response carries applied field names + masked diff, not the config: the
// backend verified exactly these fields changed, so merge them locally.
@@ -635,8 +635,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setGraphAccessRejected(false);
setGraphAccessAppMissing(false);
// The state just changed in the feature flags the dashboard banner reads.
// The state just changed in the feature flags the dashboard banner reads; the backend wrote
// it during the probe (a GET), so the fetch layer has not noted a configuration write.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
noteTenantConfigWritten();
return true;
},
[saveConfiguration],
@@ -1,18 +1,23 @@
/**
* The Settings form projections and the unsaved-changes check built on them. A freshly loaded row
* must never count as edited (otherwise the sidebar marks pages nobody touched and leaving asks for
* nothing), an edit must mark exactly its own section, and the merge after a save must clear it.
* The Settings form projections, what a save sends and the unsaved-changes check built on them. A
* freshly loaded row must never count as edited (otherwise the sidebar marks pages nobody touched
* and leaving asks for nothing), an edit must mark exactly its own section, the merge after a save
* must clear it — and a save sends what the admin changed, nothing else: a form default the row
* never stored must not travel, or a page loaded before somebody else's write reverts that write.
* Rows are built here by hand; expectations are test-local lists, never derived from the map.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { changedTenantConfigFields } from "@/lib/tenantConfigSave";
import { SECTION_FIELD_MAP, type SettingsSectionName } from "../../sectionFieldMap";
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
import { SAVE_BAR_SECTIONS, type SettingsSectionName } from "../../sectionFieldMap";
import type { NotificationChannel, TenantConfiguration } from "../../types";
import {
DEFAULT_SETTINGS_FORM,
configFromForm,
formFromConfig,
hardwareRejectionNotify,
sectionSavePayload,
sectionsWithUnsavedEdits,
settingsFormSetters,
type SettingsForm,
@@ -108,12 +113,9 @@ const LOADED_ROWS: Record<string, () => TenantConfiguration> = {
}),
};
/** What runSectionSave sends for a section, and the row after its merge. */
/** The row after a section save: what the save sends, merged like the settings provider merges it. */
function saveAndMerge(row: TenantConfiguration, form: SettingsForm, section: SettingsSectionName): TenantConfiguration {
const spec = SECTION_FIELD_MAP[section];
const alsoWrites = "alsoWrites" in spec ? spec.alsoWrites : [];
const patch = changedTenantConfigFields(row, configFromForm(row, form), [...spec.fields, ...alsoWrites]);
return { ...row, ...patch } as TenantConfiguration;
return { ...row, ...sectionSavePayload(row, form, section) } as TenantConfiguration;
}
function unsaved(row: TenantConfiguration, form: SettingsForm): string[] {
@@ -299,23 +301,229 @@ describe("formFromConfig / configFromForm", () => {
expect(config.webhookUrl).toBeUndefined();
});
it("a single log-level edit on a row that stores every agent parameter sends only the log level", () => {
// A row missing a field would also send that field's default on the first save, as before.
const row: TenantConfiguration = {
...fullRow(),
enableGeoLocation: true,
enableTimezoneAutoSet: false,
enableDoGroupIdAutoSet: false,
keepAwakeDuringUserEsp: false,
enableImeMatchLog: false,
enableGatherRuleDebugLog: false,
showScriptOutput: true,
enrollmentSummaryTimeoutSeconds: 60,
enrollmentSummaryLaunchRetrySeconds: 120,
};
});
describe("sectionSavePayload", () => {
it("is the one place that decides what the settings provider sends", () => {
// A second diff next to it (the raw row against the form) is how untouched defaults travelled.
const provider = readFileSync(join(__dirname, "..", "..", "TenantConfigContext.tsx"), "utf8");
expect(provider).toContain("sectionSavePayload(config, form, sectionName, overrides)");
expect(provider).not.toContain("changedTenantConfigFields");
});
it.each(Object.entries(LOADED_ROWS))("a freshly loaded row sends nothing in any section: %s", (_, build) => {
const row = build();
for (const section of SAVE_BAR_SECTIONS) {
expect(sectionSavePayload(row, formFromConfig(row), section)).toEqual({});
}
});
it("a single edit sends only that field, also on a row that never stored the fields beside it", () => {
// minimalRow stores none of the agent parameters: their form defaults must stay at home.
const row = minimalRow();
const form = { ...formFromConfig(row), logLevel: "Debug" };
const spec = SECTION_FIELD_MAP.agentParameters;
expect(changedTenantConfigFields(row, configFromForm(row, form), spec.fields)).toEqual({ logLevel: "Debug" });
expect(sectionSavePayload(row, form, "agentParameters")).toEqual({ logLevel: "Debug" });
});
it("a field the row never stored travels once the admin changes it, and no longer after he takes it back", () => {
const row = minimalRow();
const loaded = formFromConfig(row);
expect(sectionSavePayload(row, { ...loaded, enableTimezoneAutoSet: true }, "agentParameters")).toEqual({ enableTimezoneAutoSet: true });
expect(sectionSavePayload(row, { ...loaded, enableTimezoneAutoSet: false }, "agentParameters")).toEqual({});
});
it("a page loaded before somebody else's write does not revert that write", () => {
// Loaded: the timezone was never set. Since then the setup guide stored it as on (another
// instance answered this page from its older copy). The admin edits the log level and saves.
const loadedBefore = minimalRow();
const storedNow: TenantConfiguration = { ...minimalRow(), enableTimezoneAutoSet: true, enableSoftwareInventoryAnalyzer: true };
const form = { ...formFromConfig(loadedBefore), logLevel: "Debug" };
const afterSave = { ...storedNow, ...sectionSavePayload(loadedBefore, form, "agentParameters") } as TenantConfiguration;
expect(afterSave.logLevel).toBe("Debug");
expect(afterSave.enableTimezoneAutoSet).toBe(true);
expect(afterSave.enableSoftwareInventoryAnalyzer).toBe(true);
});
it.each(Object.entries(LOADED_ROWS))("unsaved means exactly: a save would send something: %s", (_, build) => {
const row = build();
const edits: SettingsForm[] = [
formFromConfig(row),
{ ...formFromConfig(row), logLevel: "Trace" },
{ ...formFromConfig(row), manufacturerWhitelist: "Contoso*" },
toggleHardwareNotify(formFromConfig(row), !hardwareRejectionNotify(formFromConfig(row))),
];
for (const form of edits) {
const sending = SAVE_BAR_SECTIONS.filter((section) => Object.keys(sectionSavePayload(row, form, section)).length > 0);
expect([...sending].sort()).toEqual(unsaved(row, form));
}
});
describe("sections that save on change", () => {
it("send exactly the switch that was flipped", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "autopilotValidation", { validateCloudPcDevice: true })).toEqual({ validateCloudPcDevice: true });
expect(sectionSavePayload(row, formFromConfig(row), "unrestrictedMode", { unrestrictedMode: true })).toEqual({ unrestrictedMode: true });
});
it("send the flipped switch even when the loaded copy already holds that value", () => {
// The copy may be outdated; an explicit click is never swallowed by it.
const row = fullRow();
expect(row.validateAutopilotDevice).toBe(true);
expect(sectionSavePayload(row, formFromConfig(row), "autopilotValidation", { validateAutopilotDevice: true })).toEqual({ validateAutopilotDevice: true });
});
it("send nothing from the form: a gate that only differs there is not a click", () => {
const row = fullRow();
const form = { ...formFromConfig(row), validateDeviceAssociation: true, validateIntuneDeviceBinding: true };
expect(sectionSavePayload(row, form, "autopilotValidation")).toEqual({});
expect(sectionSavePayload(row, form, "autopilotValidation", { validateCloudPcDevice: false })).toEqual({ validateCloudPcDevice: false });
});
it("drop a field the section does not own", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "unrestrictedMode", { validateAutopilotDevice: false })).toEqual({});
});
});
it("a section with a Save bar ignores switch values", () => {
const row = fullRow();
expect(sectionSavePayload(row, formFromConfig(row), "agentParameters", { unrestrictedMode: true })).toEqual({});
});
describe("hardware card", () => {
it("a whitelist edit sends the whitelist and leaves the channels alone, whatever their stored formatting", () => {
for (const build of [fullRow, LOADED_ROWS["channels stored with other formatting"], LOADED_ROWS["malformed JSON lists"]]) {
const row = build();
const form = { ...formFromConfig(row), manufacturerWhitelist: "Dell*" };
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
}
});
it("a whitelist edit does not carry a channel edit waiting on the notifications card", () => {
const row = fullRow();
const loaded = formFromConfig(row);
const form = {
...loaded,
manufacturerWhitelist: "Dell*",
notificationChannels: loaded.notificationChannels.map((c) => ({ ...c, name: `${c.name}!` })),
};
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
});
it("the moved switch sends the flag and its write-through into the channels", () => {
const row = fullRow();
const form = toggleHardwareNotify(formFromConfig(row), true);
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({
webhookNotifyOnHardwareRejection: true,
notificationChannelsJson: JSON.stringify(form.notificationChannels),
});
});
it("the switch moved and moved back sends nothing, also when the legacy flag behind it differs", () => {
const row = { ...fullRow(), webhookNotifyOnHardwareRejection: true };
const form = toggleHardwareNotify(toggleHardwareNotify(formFromConfig(row), true), false);
expect(form.webhookNotifyOnHardwareRejection).toBe(false);
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({});
expect(sectionSavePayload(row, { ...form, modelWhitelist: "Latitude*" }, "hardwareWhitelist")).toEqual({ modelWhitelist: "Latitude*" });
});
it("without channels the switch is the legacy flag alone", () => {
const row = { ...minimalRow(), webhookNotifyOnHardwareRejection: false };
expect(sectionSavePayload(row, toggleHardwareNotify(formFromConfig(row), true), "hardwareWhitelist")).toEqual({ webhookNotifyOnHardwareRejection: true });
});
});
describe("tenant with the legacy single webhook", () => {
const legacy = LOADED_ROWS["legacy single webhook without channels"];
it("a whitelist edit leaves the webhook as it is stored", () => {
const row = legacy();
const form = { ...formFromConfig(row), manufacturerWhitelist: "Dell*" };
expect(sectionSavePayload(row, form, "hardwareWhitelist")).toEqual({ manufacturerWhitelist: "Dell*" });
});
it("deleting the synthesized channel clears the legacy webhook with it", () => {
const row = legacy();
const form = { ...formFromConfig(row), notificationChannels: [] };
expect(sectionSavePayload(row, form, "notifications")).toEqual({
notificationChannelsJson: "",
webhookProviderType: 0,
webhookUrl: null,
});
});
it("the hardware switch materializes the channel and does not touch the legacy webhook fields", () => {
const row = legacy();
const form = toggleHardwareNotify(formFromConfig(row), false);
const payload = sectionSavePayload(row, form, "hardwareWhitelist");
expect(Object.keys(payload).sort()).toEqual(["notificationChannelsJson", "webhookNotifyOnHardwareRejection"]);
expect(JSON.parse(payload.notificationChannelsJson as string)).toHaveLength(1);
});
});
});
describe("form defaults", () => {
// A field the admin never saved stays empty in the row, and the form shows its default for good.
// That default has to be what the backend does with an empty field; the generated manifest carries
// the model's and the agent's defaults, so a change there without the form fails here.
it("a default row shows exactly the form's defaults", () => {
const defaults = SHARED_MANIFEST.tenantConfiguration.defaults as unknown as TenantConfiguration;
expect(formFromConfig(defaults)).toEqual(DEFAULT_SETTINGS_FORM);
});
it("match what the agent does when a setting was never stored", () => {
const { responseDefaults: agent, collectorDefaults: collectors, analyzerDefaults: analyzers } = SHARED_MANIFEST.agentConfig;
const form = formFromConfig(minimalRow());
expect({
selfDestructOnComplete: form.selfDestructOnComplete,
keepLogFile: form.keepLogFile,
enableGeoLocation: form.enableGeoLocation,
enableImeMatchLog: form.enableImeMatchLog,
enableGatherRuleDebugLog: form.enableGatherRuleDebugLog,
logLevel: form.logLevel,
rebootOnComplete: form.rebootOnComplete,
rebootDelaySeconds: form.rebootDelaySeconds,
showEnrollmentSummary: form.showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds: form.enrollmentSummaryTimeoutSeconds,
enrollmentSummaryLaunchRetrySeconds: form.enrollmentSummaryLaunchRetrySeconds,
diagnosticsUploadMode: form.diagnosticsUploadMode,
unrestrictedMode: form.unrestrictedMode,
enableTimezoneAutoSet: form.enableTimezoneAutoSet,
enableDoGroupIdAutoSet: form.enableDoGroupIdAutoSet,
}).toEqual({
selfDestructOnComplete: agent.selfDestructOnComplete,
keepLogFile: agent.keepLogFile,
enableGeoLocation: agent.enableGeoLocation,
enableImeMatchLog: agent.enableImeMatchLog,
enableGatherRuleDebugLog: agent.enableGatherRuleDebugLog,
logLevel: agent.logLevel,
rebootOnComplete: agent.rebootOnComplete,
rebootDelaySeconds: agent.rebootDelaySeconds,
showEnrollmentSummary: agent.showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds: agent.enrollmentSummaryTimeoutSeconds,
enrollmentSummaryLaunchRetrySeconds: agent.enrollmentSummaryLaunchRetrySeconds,
diagnosticsUploadMode: agent.diagnosticsUploadMode,
unrestrictedMode: agent.unrestrictedMode,
enableTimezoneAutoSet: agent.enableTimezoneAutoSet,
enableDoGroupIdAutoSet: agent.enableDoGroupIdAutoSet,
});
expect({
enableLocalAdminAnalyzer: form.enableLocalAdminAnalyzer,
localAdminAllowedAccounts: form.localAdminAllowedAccounts,
enableSoftwareInventoryAnalyzer: form.enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer: form.enableIntegrityBypassAnalyzer,
enableRealmJoinWatcher: form.enableRealmJoinWatcher,
enableConfigMgrWatcher: form.enableConfigMgrWatcher,
keepAwakeDuringUserEsp: form.keepAwakeDuringUserEsp,
enableConsoleBypassDetection: form.enableConsoleBypassDetection,
}).toEqual(analyzers);
// minimalRow stores the collector values itself; the defaults are what a row without them shows.
const bare = formFromConfig({ ...minimalRow(), enablePerformanceCollector: undefined, performanceCollectorIntervalSeconds: undefined } as unknown as TenantConfiguration);
expect(bare.enablePerformanceCollector).toBe(collectors.enablePerformanceCollector);
expect(bare.performanceCollectorInterval).toBe(collectors.performanceIntervalSeconds);
expect(bare.helloWaitTimeoutSeconds).toBe(collectors.helloWaitTimeoutSeconds);
});
});
@@ -4,6 +4,7 @@ import { HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS } from "./helloWaitTimeout";
import {
SAVE_BAR_SECTIONS,
SECTION_FIELD_MAP,
type SectionFieldSpec,
type SettingsSectionName,
type TenantConfigFieldName,
} from "../sectionFieldMap";
@@ -342,28 +343,80 @@ export function hardwareRejectionNotify(
: form.webhookNotifyOnHardwareRejection;
}
/** The switch a section without a Save bar has just flipped, by field. */
export type SectionSaveOverrides = Partial<Record<TenantConfigFieldName, boolean>>;
/** The stored row and the form, both written back in the stored shape; computed once per comparison. */
interface SavedAndEdited {
savedForm: SettingsForm;
saved: TenantConfiguration;
edited: TenantConfiguration;
}
function savedAndEdited(config: TenantConfiguration, form: SettingsForm): SavedAndEdited {
const savedForm = formFromConfig(config);
return { savedForm, saved: configFromForm(config, savedForm), edited: configFromForm(config, form) };
}
/** What a Save bar section sends; see {@link sectionSavePayload}. */
function saveBarPayload(
config: TenantConfiguration,
form: SettingsForm,
section: SettingsSectionName,
{ savedForm, saved, edited }: SavedAndEdited,
): Record<string, unknown> {
const spec: SectionFieldSpec = SECTION_FIELD_MAP[section];
const writeThroughs = spec.alsoWrites ?? [];
if (section === "hardwareWhitelist") {
// The rejection switch is a view over the channel flags: the legacy flag behind it and its
// write-through into the channels travel only when what the switch shows has changed.
const payload = changedTenantConfigFields(saved, edited, spec.fields.filter((f) => f !== "webhookNotifyOnHardwareRejection"));
if (hardwareRejectionNotify(savedForm) !== hardwareRejectionNotify(form)) {
Object.assign(payload, changedTenantConfigFields(saved, edited, ["webhookNotifyOnHardwareRejection", ...writeThroughs]));
}
return payload;
}
const payload = changedTenantConfigFields(saved, edited, spec.fields);
// A write-through rides along with an edit and is compared with the stored row itself: it clears
// what the projection never shows (the legacy single webhook behind the notification channels).
if (Object.keys(payload).length > 0) {
Object.assign(payload, changedTenantConfigFields(config, edited, writeThroughs));
}
return payload;
}
/**
* The sections with a Save bar whose fields the user changed: the stored and the edited values
* pass through the same projection before they are compared, so only an edit counts. A section
* that saves on change (`immediate`) never counts.
* The fields saving `section` sends: what the admin changed, nothing else. The stored and the
* edited values pass through the same projection before they are compared, so a model default
* the row never stored, a trim or a synthesized channel never travels. That is what keeps a save
* from writing a form default over a value somebody else has stored since this page loaded.
*
* A section that saves on change (`immediate`) sends exactly the switch that was flipped, also
* when the loaded copy already holds that value: the copy may be outdated, and the backend ignores
* a value it already has.
*/
export function sectionSavePayload(
config: TenantConfiguration,
form: SettingsForm,
section: SettingsSectionName,
overrides: SectionSaveOverrides = {},
): Record<string, unknown> {
const spec: SectionFieldSpec = SECTION_FIELD_MAP[section];
if (spec.immediate) {
return Object.fromEntries(
Object.entries(overrides).filter(([field, value]) => value != null && (spec.fields as readonly string[]).includes(field)),
);
}
return saveBarPayload(config, form, section, savedAndEdited(config, form));
}
/**
* The sections with a Save bar that hold unsaved edits: exactly those whose save would send
* something. A section that saves on change (`immediate`) never counts.
*/
export function sectionsWithUnsavedEdits(config: TenantConfiguration, form: SettingsForm): ReadonlySet<SettingsSectionName> {
const savedForm = formFromConfig(config);
const saved = configFromForm(config, savedForm);
const edited = configFromForm(config, form);
const unsaved = new Set<SettingsSectionName>();
for (const section of SAVE_BAR_SECTIONS) {
let fields: readonly TenantConfigFieldName[] = SECTION_FIELD_MAP[section].fields;
let switchChanged = false;
if (section === "hardwareWhitelist") {
// Compare what the switch shows, not the legacy flag behind it: with channels the switch
// writes through to them, and the legacy flag alone may not move.
fields = fields.filter((f) => f !== "webhookNotifyOnHardwareRejection");
switchChanged = hardwareRejectionNotify(savedForm) !== hardwareRejectionNotify(form);
}
if (switchChanged || Object.keys(changedTenantConfigFields(saved, edited, fields)).length > 0) {
unsaved.add(section);
}
}
return unsaved;
const projections = savedAndEdited(config, form);
return new Set(SAVE_BAR_SECTIONS.filter((section) => Object.keys(saveBarPayload(config, form, section, projections)).length > 0));
}
@@ -23,11 +23,12 @@ export interface SectionFieldSpec {
}
/**
* Section → exact TenantConfiguration fields each Settings section saves. TenantConfigContext
* builds its per-section PATCH from this map: only the section's fields (plus alsoWrites) that
* actually DIFFER from the loaded config are sent — the other ~90 fields never round-trip, so
* a stale read can no longer revert unrelated fields (the 2026-07-31 incident class). The same
* map decides which sections can hold unsaved edits and where the sidebar marks them.
* Section → exact TenantConfiguration fields each Settings section saves. The per-section PATCH
* is built from this map (lib/settingsForm.ts, sectionSavePayload): only the section's fields the
* admin CHANGED travel, plus its alsoWrites — untouched fields never round-trip, in this section
* or any other, so a page loaded before somebody else's write cannot revert it (the 2026-07-31
* incident class). The same map decides which sections can hold unsaved edits and where the
* sidebar marks them.
*/
export const SECTION_FIELD_MAP = {
hardwareWhitelist: {
@@ -1,5 +1,10 @@
"use client";
import { Suspense, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useContinueSetup } from "@/hooks/useSetupChecklist";
import { parseSetupStep } from "@/lib/setupGuide";
import { useSetupGuideDialog } from "@/lib/setupGuideStore";
import { SetupGuideModal } from "./SetupGuideModal";
@@ -7,9 +12,44 @@ import { SetupGuideModal } from "./SetupGuideModal";
* Mounts the setup guide dialog once, in the root layout; the dashboard card, the sidebar entry,
* the help menu and the settings tour open it through the module store. Unmounted while closed,
* so every opening starts at the requested (or first open) step.
*
* `?setup-guide=settings|hardware|validation|deploy` opens it at that step, also for a tenant
* that finished the setup: nothing is reset, the steps show the tenant's real configuration.
*/
export function SetupGuideHost() {
const { open, step } = useSetupGuideDialog();
if (!open) return null;
return <SetupGuideModal initialStep={step} />;
return (
<>
{/* useSearchParams() needs a Suspense boundary for the static prerender. */}
<Suspense fallback={null}>
<SetupGuideDeepLink />
</Suspense>
{open && <SetupGuideModal initialStep={step} />}
</>
);
}
/**
* Opens the guide once each time the parameter arrives, not only on the first mount: a link
* opened without a session goes through the sign-in and reaches the page again by a client-side
* navigation, where this layout stays mounted. Admins only, like the help menu entry.
*/
function SetupGuideDeepLink() {
const requested = parseSetupStep(useSearchParams()?.get("setup-guide"));
const { user, isAuthenticated, isLoading } = useAuth();
const continueSetup = useContinueSetup();
const allowed = isAuthenticated && !isLoading && !!(user?.isTenantAdmin || user?.isGlobalAdmin);
const handled = useRef(false);
useEffect(() => {
if (requested === null) {
handled.current = false;
return;
}
if (handled.current || !allowed) return;
handled.current = true;
continueSetup(requested, "deeplink");
}, [requested, allowed, continueSetup]);
return null;
}
@@ -31,6 +31,7 @@ import {
} from "@/lib/setupGuide";
import { closeSetupGuide, markSetup, startSetupTour } from "@/lib/setupGuideStore";
import { recordSetupGuideStep, writeGuideConfigFields } from "@/lib/setupGuideWrites";
import { subscribeTenantConfigPatches } from "@/lib/tenantConfigPatches";
const STEP_TITLES: Record<SetupStepId, string> = {
settings: "Recommended settings",
@@ -73,6 +74,13 @@ export function SetupGuideModal({ initialStep }: { initialStep: SetupStepId | nu
};
}, [tenantId, getAccessToken]);
// The guide's own writes go into the copy the steps read: after "Back" a step shows what was
// just applied instead of offering it again.
useEffect(() => subscribeTenantConfigPatches((patchedTenantId, fields) => {
if (patchedTenantId !== tenantId) return;
setConfig((prev) => (prev ? ({ ...prev, ...fields } as TenantConfiguration) : prev));
}), [tenantId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSetupGuide();
@@ -160,7 +168,14 @@ export function SetupGuideModal({ initialStep }: { initialStep: SetupStepId | nu
)}
{step === "validation" && <ValidationStep onContinue={() => goTo("validation")} />}
{step === "deploy" && <DeployStep config={config} />}
{step === "complete" && <CompleteStep />}
{step === "complete" && (
<CompleteStep
onRestart={() => {
trackEvent("setup_guide_opened", { source: "again", step: "settings" });
goTo("settings");
}}
/>
)}
{checklist?.dismissed && !checklist.done.deploy && canEdit && (
<RestoreLine tenantId={tenantId} getAccessToken={getAccessToken} />
@@ -396,7 +411,7 @@ function HardwareStep({ config, loadError, tenantId, canEdit, getAccessToken, on
</p>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Accepted now</p>
<div className="flex flex-wrap gap-1.5">
{entries.length === 0 && <span className="text-sm text-red-600 dark:text-red-400">Nothing is accepted: every device would be rejected.</span>}
{entries.length === 0 && <span className="text-sm text-red-600 dark:text-red-400">Add at least one manufacturer.</span>}
{entries.map((entry) => (
<span key={entry} className={`${chip} pr-1 bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-800`}>
{entry}
@@ -568,14 +583,14 @@ function DeployStep({ config }: { config: TenantConfiguration | null }) {
);
}
function CompleteStep() {
function CompleteStep({ onRestart }: { onRestart: () => void }) {
return (
<>
<div className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Your tenant receives enrollments. Every setting the guide touched stays under Configuration.
</div>
<Footer>
<span />
<button type="button" className={linkButton} onClick={onRestart}>Go through the steps again</button>
<button type="button" className={primaryButton} onClick={closeSetupGuide}>Close</button>
</Footer>
</>
@@ -26,6 +26,7 @@ import { clearCachedAuthFetch } from '@/lib/cachedAuthFetch';
import { trackEvent } from '@/lib/appInsights';
import { seedDashboardFirstFetch } from '@/lib/dashboardSeed';
import { captureSignupConsentFromUrl, hasSignupConsent, stripSignupConsentParam } from '@/lib/signupConsent';
import { storedConfigHeaders } from '@/lib/storedConfigHint';
import { abandonViewAs, captureViewAsFromUrl, stripViewAsParams, viewAsHeaders } from '@/lib/viewAs';
import { peekPostLoginReturnUrl } from '@/lib/postLoginReturn';
import { DEFAULT_PORTAL_LANDING } from '@/lib/hostRouting';
@@ -228,7 +229,7 @@ const msalInitPromise = msalInstance
// behind it (consumed once by authenticatedFetch, see lib/dashboardSeed.ts).
seedDashboardFirstFetch(tokenResponse.accessToken, accounts[0].tenantId);
const res = await fetch(api.auth.me({ signupConsent: hasSignupConsent() }), {
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}`, ...viewAsHeaders() },
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}`, ...viewAsHeaders(), ...storedConfigHeaders() },
signal: AbortSignal.timeout(8000),
});
return res.ok ? (await res.json()) as AuthMeResponse : null;
@@ -472,6 +473,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
headers: {
'Authorization': `Bearer ${tokenResponse.accessToken}`,
...viewAsHeaders(),
// After the terms tick or a settings save, auth/me must not come from an older copy.
...storedConfigHeaders(),
},
signal: authMeController.signal,
});
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
vi.mock("../viewAs", () => ({ viewAsHeaders: vi.fn(() => ({})) }));
function response(status: number): Response {
return new Response(status === 204 ? null : "{}", { status, headers: { "Content-Type": "application/json" } });
}
const fetchMock = () => globalThis.fetch as ReturnType<typeof vi.fn>;
const token = () => vi.fn().mockResolvedValue("tok");
const hintOf = (call: number) => (fetchMock().mock.calls[call][1].headers as Headers).get("X-Tenant-Config");
/**
* A browser with localStorage and a freshly loaded fetch layer. The mark has an in-memory part
* that lives as long as the page, so every page load gets its own module instance.
*/
async function pageLoad(browser: Map<string, string>) {
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => browser.get(key) ?? null,
setItem: (key: string, value: string) => void browser.set(key, value),
},
});
vi.resetModules();
return (await import("../authenticatedFetch")).authenticatedFetch;
}
describe("authenticatedFetch after a configuration write", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("asks for the stored row on every later call", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/sessions", token());
expect([hintOf(0), hintOf(1), hintOf(2), hintOf(3)]).toEqual([null, null, "stored", "stored"]);
});
it("leaves the cache mode alone: with no-store the browser would preflight every call again", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
await authenticatedFetch("/api/sessions", token(), { cache: "no-cache" });
expect(hintOf(1)).toBe("stored");
expect(fetchMock().mock.calls[1][1].cache).toBeUndefined();
expect(fetchMock().mock.calls[2][1].cache).toBe("no-cache");
});
it("still asks after a reload: the consent redirect returns to a fresh page within seconds", async () => {
const browser = new Map<string, string>();
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await (await pageLoad(browser))("/api/config/t1/setup-guide", token(), { method: "POST", body: "{}" });
await (await pageLoad(browser))("/api/config/t1", token());
expect(hintOf(1)).toBe("stored");
});
it("counts the terms confirmation: auth/me must not answer from an older copy", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockResolvedValueOnce(response(204)).mockResolvedValueOnce(response(200));
await authenticatedFetch("/api/auth/dpa-acceptance", token(), { method: "POST" });
await authenticatedFetch("/api/auth/me", token());
expect(hintOf(1)).toBe("stored");
});
it("counts a write that needed the 401 retry", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock()
.mockResolvedValueOnce(response(401))
.mockResolvedValueOnce(response(200))
.mockResolvedValueOnce(response(200));
const getToken = vi.fn().mockResolvedValueOnce("stale").mockResolvedValue("fresh");
await authenticatedFetch("/api/config/t1/fields", getToken, { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", getToken);
expect(hintOf(2)).toBe("stored");
});
it("leaves no mark after a failed write", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockResolvedValueOnce(response(400)).mockResolvedValueOnce(response(200));
await authenticatedFetch("/api/config/t1/fields", token(), { method: "PATCH", body: "{}" });
await authenticatedFetch("/api/config/t1", token());
expect(hintOf(1)).toBeNull();
});
it("leaves no mark after a write that is not a configuration write", async () => {
const authenticatedFetch = await pageLoad(new Map());
fetchMock().mockImplementation(() => Promise.resolve(response(200)));
await authenticatedFetch("/api/realtime/groups/join", token(), { method: "POST", body: "{}" });
await authenticatedFetch("/api/sessions/abc", token(), { method: "DELETE" });
await authenticatedFetch("/api/config/t1", token());
expect(hintOf(2)).toBeNull();
expect(fetchMock().mock.calls[2][1].cache).toBeUndefined();
});
});
@@ -3,8 +3,13 @@ import {
extractScriptRefsFromEvents,
fetchScriptDisplayNames,
lookupScriptDisplayName,
lookUpWithOneFollowUp,
mergeFollowUpNames,
formatRefKey,
scriptNamesLookupKey,
SCRIPT_DISPLAY_NAMES_CHUNK_SIZE,
type DisplayNamesByRefKey,
type ScriptNamesAnswer,
} from "../scriptDisplayNames";
function mockResponse(status: number, body: unknown = {}): Response {
@@ -76,7 +81,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
it("returns empty when no refs are passed (no HTTP call)", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const result = await fetchScriptDisplayNames(TENANT, [], getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, [], getToken);
expect(result).toEqual({});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
@@ -87,7 +92,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
refs: { "Platform:abc": "Bootstrap.ps1", "Remediation:rem-1": null },
}));
const result = await fetchScriptDisplayNames(
const { names: result } = await fetchScriptDisplayNames(
TENANT,
[{ kind: "Platform", id: "abc" }, { kind: "Remediation", id: "rem-1" }],
getToken,
@@ -110,7 +115,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
const getToken = vi.fn().mockResolvedValue("tok");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResponse(500, { error: "boom" }));
const result = await fetchScriptDisplayNames(
const { names: result } = await fetchScriptDisplayNames(
TENANT,
[{ kind: "Platform", id: "abc" }],
getToken,
@@ -138,7 +143,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
.mockResolvedValueOnce(mockResponse(200, { refs: firstChunkResponse }))
.mockResolvedValueOnce(mockResponse(200, { refs: secondChunkResponse }));
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(Object.keys(result)).toHaveLength(SCRIPT_DISPLAY_NAMES_CHUNK_SIZE + 1);
@@ -164,7 +169,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:id-0": "first-batch-ok" } }))
.mockResolvedValueOnce(mockResponse(503, { error: "throttled" }));
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
// Don't discard the work the first chunk already did.
expect(result["Platform:id-0"]).toBe("first-batch-ok");
@@ -188,7 +193,7 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
// Must not throw — the merged-so-far map should come back, with the failed chunk's
// ids simply absent. Earlier behaviour bubbled the network error to the hook, which
// then discarded the successful first chunk too.
const result = await fetchScriptDisplayNames(TENANT, refs, getToken);
const { names: result } = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(result["Platform:id-0"]).toBe("first-batch-ok");
expect(result["Platform:id-200"]).toBeUndefined();
@@ -202,6 +207,138 @@ describe("fetchScriptDisplayNames (POST shape)", () => {
await expect(fetchScriptDisplayNames(TENANT, refs, getToken)).rejects.toBeInstanceOf(TokenExpiredError);
});
it("passes on the backend's timedOut flag and reports false when it is absent", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const refs = [{ kind: "Platform" as const, id: "abc" }];
(globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:abc": null }, timedOut: true }))
.mockResolvedValueOnce(mockResponse(200, { refs: { "Platform:abc": "Bootstrap.ps1" } }));
const cut = await fetchScriptDisplayNames(TENANT, refs, getToken);
const complete = await fetchScriptDisplayNames(TENANT, refs, getToken);
expect(cut.timedOut).toBe(true);
expect(complete.timedOut).toBe(false);
});
it("labels only the follow-up request with retry", async () => {
const getToken = vi.fn().mockResolvedValue("tok");
const refs = [{ kind: "Platform" as const, id: "abc" }];
(globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockResponse(200, { refs: {} }))
.mockResolvedValueOnce(mockResponse(200, { refs: {} }));
await fetchScriptDisplayNames(TENANT, refs, getToken);
await fetchScriptDisplayNames(TENANT, refs, getToken, true);
const bodies = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.map(c => JSON.parse(c[1].body as string));
expect(bodies[0]).toEqual({ refs: ["Platform:abc"] });
expect(bodies[1]).toEqual({ refs: ["Platform:abc"], retry: true });
});
});
describe("mergeFollowUpNames", () => {
it("adds what the follow-up resolved and keeps what only the first answer had", () => {
const first = { "Platform:a": "A", "Platform:b": null, "Remediation:c": "C" };
const followUp = { "Platform:a": "A", "Platform:b": "B", "Remediation:c": null };
expect(mergeFollowUpNames(first, followUp)).toEqual({
"Platform:a": "A",
"Platform:b": "B",
// The follow-up was cut short at another point and lost this name; it stays.
"Remediation:c": "C",
});
});
it("keeps every name when the follow-up comes back empty", () => {
const first = { "Platform:a": "A", "Platform:b": null };
expect(mergeFollowUpNames(first, {})).toEqual(first);
});
});
describe("lookUpWithOneFollowUp", () => {
const answer = (names: DisplayNamesByRefKey, timedOut: boolean): ScriptNamesAnswer => ({ names, timedOut });
/** `cancelDuring` makes the question stale while that step is in flight (a session switch, say). */
function run(answers: ScriptNamesAnswer[], cancelDuring?: "firstAsk" | "wait" | "followUp") {
const asks: boolean[] = [];
const published: DisplayNamesByRefKey[] = [];
let waits = 0;
let cancelled = false;
const done = lookUpWithOneFollowUp(
async isFollowUp => {
asks.push(isFollowUp);
if (cancelDuring === (isFollowUp ? "followUp" : "firstAsk")) cancelled = true;
return answers[asks.length - 1];
},
names => { published.push(names); },
async () => {
waits++;
if (cancelDuring === "wait") cancelled = true;
},
() => cancelled,
);
return { done, asks, published, waits: () => waits };
}
it("asks once when the answer is complete", async () => {
const r = run([answer({ "Platform:a": "A" }, false)]);
await r.done;
expect(r.asks).toEqual([false]);
expect(r.waits()).toBe(0);
expect(r.published).toEqual([{ "Platform:a": "A" }]);
});
it("waits and asks exactly once more when a time limit cut the answer short", async () => {
const r = run([
answer({ "Platform:a": "A", "Platform:b": null }, true),
answer({ "Platform:a": "A", "Platform:b": "B" }, false),
]);
await r.done;
expect(r.asks).toEqual([false, true]);
expect(r.waits()).toBe(1);
// The first names are on screen while the follow-up runs, then the full set replaces them.
expect(r.published).toEqual([
{ "Platform:a": "A", "Platform:b": null },
{ "Platform:a": "A", "Platform:b": "B" },
]);
});
it("stops after one follow-up even when that is cut short too", async () => {
const r = run([
answer({ "Platform:a": "A", "Platform:b": null }, true),
answer({ "Platform:a": null, "Platform:b": null }, true),
answer({ "Platform:a": "A", "Platform:b": "B" }, false),
]);
await r.done;
expect(r.asks).toEqual([false, true]);
expect(r.waits()).toBe(1);
// The follow-up knew less than the first answer: nothing already shown is taken away.
expect(r.published[1]).toEqual({ "Platform:a": "A", "Platform:b": null });
});
it("publishes nothing and sends no follow-up once the question is no longer current", async () => {
const answers = [answer({ "Platform:a": null }, true), answer({ "Platform:a": "A" }, false)];
const duringFirstAsk = run(answers, "firstAsk");
await duringFirstAsk.done;
expect(duringFirstAsk.published).toEqual([]);
expect(duringFirstAsk.asks).toEqual([false]);
const duringWait = run(answers, "wait");
await duringWait.done;
expect(duringWait.asks).toEqual([false]); // the follow-up is never sent
expect(duringWait.published).toEqual([{ "Platform:a": null }]);
const duringFollowUp = run(answers, "followUp");
await duringFollowUp.done;
expect(duringFollowUp.asks).toEqual([false, true]);
expect(duringFollowUp.published).toEqual([{ "Platform:a": null }]); // its answer is dropped
});
});
describe("lookupScriptDisplayName", () => {
@@ -231,6 +368,34 @@ describe("lookupScriptDisplayName", () => {
});
});
describe("scriptNamesLookupKey", () => {
const TENANT_ID = "11111111-1111-1111-1111-111111111111";
const REFS_KEY = "Platform:abc|Remediation:def";
it("is empty until the caller's event list is complete", () => {
// An empty key keeps the hook from fetching: the session page loads events page by page,
// and a lookup per page would repeat the request for every page that adds a script.
expect(scriptNamesLookupKey(TENANT_ID, REFS_KEY, false)).toBe("");
});
it("joins tenant and refset once the caller is ready", () => {
expect(scriptNamesLookupKey(TENANT_ID, REFS_KEY, true)).toBe(`${TENANT_ID}::${REFS_KEY}`);
});
it("is empty without a tenant or without script refs", () => {
expect(scriptNamesLookupKey(null, REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey(undefined, REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey("", REFS_KEY, true)).toBe("");
expect(scriptNamesLookupKey(TENANT_ID, "", true)).toBe("");
});
it("changes with the tenant and with the refset, so an answer never serves another question", () => {
const key = scriptNamesLookupKey(TENANT_ID, REFS_KEY, true);
expect(scriptNamesLookupKey("22222222-2222-2222-2222-222222222222", REFS_KEY, true)).not.toBe(key);
expect(scriptNamesLookupKey(TENANT_ID, "Platform:abc", true)).not.toBe(key);
});
});
describe("formatRefKey", () => {
it("formats canonical Kind:Id", () => {
expect(formatRefKey({ kind: "Platform", id: "abc" })).toBe("Platform:abc");
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantConfiguration } from "@/lib/generated/wire-types.generated";
import {
deriveSetupChecklist,
parseSetupStep,
parseSetupTour,
recommendedPatch,
recommendedStatus,
@@ -138,3 +139,15 @@ describe("parseSetupTour", () => {
expect(parseSetupTour(null)).toBeNull();
});
});
describe("parseSetupStep", () => {
it("accepts the four steps a link may name", () => {
expect(["settings", "hardware", "validation", "deploy"].map(parseSetupStep)).toEqual(["settings", "hardware", "validation", "deploy"]);
});
it("ignores anything else, so a link can never open a state the guide does not have", () => {
for (const raw of ["complete", "Settings", "", "reset", "showme:timezone", null, undefined]) {
expect(parseSetupStep(raw)).toBeNull();
}
});
});
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchOk } from "@/lib/apiClient";
import { invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
import { recordSetupGuideStep, writeGuideConfigFields } from "../setupGuideWrites";
import { subscribeTenantConfigPatches } from "../tenantConfigPatches";
vi.mock("@/lib/apiClient", () => ({
fetchOk: vi.fn(),
jsonBody: (value: unknown) => JSON.stringify(value),
}));
vi.mock("@/lib/cachedAuthFetch", () => ({
CONFIG_PATH_PREFIX: "/api/config/",
invalidateCachedAuthFetch: vi.fn(),
}));
const TENANT = "00000000-0000-4000-8000-000000000001";
const token = vi.fn().mockResolvedValue("tok");
/**
* The guide's two writes. Both change what the feature flags say, so both drop the cached flags:
* a checklist read right after a step must not come from the browser's own older answer.
*/
describe("setup guide writes", () => {
beforeEach(() => {
vi.mocked(fetchOk).mockReset();
vi.mocked(invalidateCachedAuthFetch).mockReset();
});
it("recording a step drops the cached flags once the server has it", async () => {
vi.mocked(fetchOk).mockResolvedValueOnce(new Response("{}"));
await recordSetupGuideStep(TENANT, "hardware", token);
expect(vi.mocked(fetchOk).mock.calls[0][2]).toMatchObject({ method: "POST", body: JSON.stringify({ step: "hardware" }) });
expect(invalidateCachedAuthFetch).toHaveBeenCalledWith("/api/config/");
});
it("a step the server refused drops nothing", async () => {
vi.mocked(fetchOk).mockRejectedValueOnce(new Error("503"));
await expect(recordSetupGuideStep(TENANT, "settings", token)).rejects.toThrow("503");
expect(invalidateCachedAuthFetch).not.toHaveBeenCalled();
});
it("a field write drops the cached flags and hands exactly its fields to whoever shows the configuration", async () => {
vi.mocked(fetchOk).mockResolvedValueOnce(new Response("{}"));
const seen: Array<[string, Readonly<Record<string, unknown>>]> = [];
const unsubscribe = subscribeTenantConfigPatches((tenantId, fields) => seen.push([tenantId, fields]));
await writeGuideConfigFields(TENANT, { manufacturerWhitelist: "Dell*" }, "setup-guide:hardware", token);
unsubscribe();
expect(seen).toEqual([[TENANT, { manufacturerWhitelist: "Dell*" }]]);
expect(invalidateCachedAuthFetch).toHaveBeenCalledWith("/api/config/");
});
it("a field write without fields sends and publishes nothing", async () => {
const listener = vi.fn();
const unsubscribe = subscribeTenantConfigPatches(listener);
await writeGuideConfigFields(TENANT, {}, "setup-guide:recommended", token);
unsubscribe();
expect(fetchOk).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
expect(invalidateCachedAuthFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { writesTenantConfig } from "../storedConfigHint";
const HEADER = { "X-Tenant-Config": "stored" };
const T0 = 1_800_000_000_000;
/** vitest runs in node: one Map is the browser's localStorage, shared by every tab that gets it. */
function stubBrowser(stored = new Map<string, string>(), denied = false) {
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => {
if (denied) throw new Error("storage denied");
return stored.get(key) ?? null;
},
setItem: (key: string, value: string) => {
if (denied) throw new Error("storage denied");
stored.set(key, value);
},
},
});
return stored;
}
/** A fresh module per tab or page load: the in-memory fallback lives as long as the page. */
async function pageLoad() {
vi.resetModules();
return import("../storedConfigHint");
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("writesTenantConfig", () => {
it.each([
["PATCH", "https://api.example.com/api/config/t1/fields", true],
["POST", "/api/config/t1/setup-guide", true],
["post", "/api/config/t1/trial?x=1", true],
["PUT", "/api/config/t1/plan", true],
["POST", "/api/config/t1/autopilot-device-validation/consent-success", true],
["POST", "/api/auth/dpa-acceptance", true],
["GET", "/api/config/t1", false],
["GET", "/api/config/t1/feature-flags", false],
["HEAD", "/api/config/t1", false],
["POST", "/api/realtime/groups/join", false],
["DELETE", "/api/sessions/abc", false],
["PATCH", "/api/global/config", false],
["POST", "/api/auth/dpa-acceptance/more", false],
["PUT", "/api/auth/me/whats-new-seen", false],
])("%s %s → %s", (method, url, expected) => {
expect(writesTenantConfig(method, url)).toBe(expected);
});
});
describe("the mark after a configuration write", () => {
it("sends nothing before any write", async () => {
stubBrowser();
const hint = await pageLoad();
expect(hint.storedConfigHeaders(T0)).toEqual({});
});
it("asks for the stored row from the write on and stops after the hint window", async () => {
stubBrowser();
const hint = await pageLoad();
hint.noteTenantConfigWritten(T0);
expect(hint.storedConfigHeaders(T0)).toEqual(HEADER);
expect(hint.storedConfigHeaders(T0 + hint.STORED_CONFIG_HINT_MS - 1)).toEqual(HEADER);
expect(hint.storedConfigHeaders(T0 + hint.STORED_CONFIG_HINT_MS)).toEqual({});
});
it("outlasts the backend's five-minute configuration cache", async () => {
const hint = await pageLoad();
expect(hint.STORED_CONFIG_HINT_MS).toBeGreaterThan(5 * 60_000);
});
it("survives a reload and reaches another tab: the consent redirect comes back to a fresh page", async () => {
const browser = stubBrowser();
(await pageLoad()).noteTenantConfigWritten(T0);
stubBrowser(browser);
const reloaded = await pageLoad();
expect(reloaded.storedConfigHeaders(T0 + 10_000)).toEqual(HEADER);
});
it("a later write extends the window", async () => {
stubBrowser();
const hint = await pageLoad();
hint.noteTenantConfigWritten(T0);
hint.noteTenantConfigWritten(T0 + 5 * 60_000);
expect(hint.storedConfigHeaders(T0 + hint.STORED_CONFIG_HINT_MS + 1_000)).toEqual(HEADER);
});
it("still covers this page when the browser refuses storage", async () => {
stubBrowser(new Map(), true);
const hint = await pageLoad();
hint.noteTenantConfigWritten(T0);
expect(hint.storedConfigHeaders(T0 + 1_000)).toEqual(HEADER);
});
it("ignores a broken value and a mark far in the future", async () => {
stubBrowser(new Map([["am_config_written", "not-a-number"]]));
expect((await pageLoad()).storedConfigHeaders(T0)).toEqual({});
stubBrowser(new Map([["am_config_written", String(T0 + 24 * 3_600_000)]]));
expect((await pageLoad()).storedConfigHeaders(T0)).toEqual({});
});
it("sends nothing where there is no browser", async () => {
const hint = await pageLoad();
hint.noteTenantConfigWritten(T0);
expect(hint.storedConfigHeaders(T0)).toEqual({});
});
});
@@ -14,6 +14,7 @@
import { trackEvent } from "./appInsights";
import { CORRELATION_HEADER, newCorrelationId } from "./correlationId";
import { takeSeed } from "./prefetchSeeds";
import { noteTenantConfigWritten, storedConfigHeaders, writesTenantConfig } from "./storedConfigHint";
import { viewAsHeaders } from "./viewAs";
/** Default request timeout in milliseconds */
@@ -79,16 +80,30 @@ export async function authenticatedFetch(
// A view-as tab (lib/viewAs.ts) names the viewed tenant on every call. It shares the browser's HTTP
// cache with ordinary tabs and no response varies on those headers, so it bypasses that cache.
// A browser that has just written a tenant configuration asks for the stored row
// (lib/storedConfigHint.ts). That header leaves the cache mode alone: with "no-store" the browser
// preflights every call again, and the API's answers carry no freshness lifetime to serve from.
const viewAs = viewAsHeaders();
const extraHeaders = { ...viewAs, ...storedConfigHeaders() };
const cache = Object.keys(viewAs).length > 0 ? 'no-store' : init?.cache;
const headersFor = (bearer: string): Headers => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${bearer}`);
headers.set(CORRELATION_HEADER, correlationId);
for (const [name, value] of Object.entries(viewAs)) headers.set(name, value);
for (const [name, value] of Object.entries(extraHeaders)) headers.set(name, value);
return headers;
};
const settle = (final: Response): Response => {
if (final.status >= 400) {
trackFailure(url, method, final.status, correlationId);
} else if (final.ok && writesTenantConfig(method, url)) {
// From here on this browser's reads ask for the stored row: another instance may answer them.
noteTenantConfigWritten();
}
return final;
};
// Apply a default timeout unless the caller already provided an AbortSignal
const signal = init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
@@ -106,10 +121,8 @@ export async function authenticatedFetch(
if (retryResponse.status === 401) {
throw new TokenExpiredError();
}
if (retryResponse.status >= 400) trackFailure(url, method, retryResponse.status, correlationId);
return retryResponse;
return settle(retryResponse);
}
if (response.status >= 400) trackFailure(url, method, response.status, correlationId);
return response;
return settle(response);
}
@@ -1,6 +1,7 @@
import { api } from "@/lib/api";
import { CORRELATION_HEADER, newCorrelationId } from "@/lib/correlationId";
import { registerSeed } from "@/lib/prefetchSeeds";
import { storedConfigHeaders } from "@/lib/storedConfigHint";
import { readViewAs } from "@/lib/viewAs";
import {
DASHBOARD_STATS_DEFAULT_DAYS,
@@ -43,7 +44,8 @@ export function seedDashboardFirstFetch(accessToken: string, tenantId: string |
url,
correlationId,
fetch(url, {
headers: { Authorization: `Bearer ${accessToken}`, [CORRELATION_HEADER]: correlationId },
// The same headers authenticatedFetch would send for this URL.
headers: { Authorization: `Bearer ${accessToken}`, [CORRELATION_HEADER]: correlationId, ...storedConfigHeaders() },
signal: AbortSignal.timeout(30_000),
}),
);
@@ -11771,6 +11771,15 @@
},
"optional": true,
"doc": "Ref tokens from the request that failed to parse, or null when there were none — the key is omitted when null (the empty-body and empty-refs early-exit sites never wrote this key at all; they leave it null)."
},
{
"name": "timedOut",
"type": {
"kind": "primitive",
"name": "boolean"
},
"optional": true,
"doc": "True when the lookup ran into one of its time limits before every ref had an answer, so asking again can return more names. Null otherwise — the key is omitted. A failed Graph call does not set it."
}
]
},
@@ -21926,6 +21935,16 @@
}
},
"optional": true
},
{
"name": "retry",
"type": {
"kind": "primitive",
"name": "boolean",
"nullable": true
},
"optional": true,
"doc": "True on the one follow-up lookup a client sends after a response with timedOut. The lookup itself runs the same; the flag only labels the request."
}
]
},
@@ -2445,6 +2445,8 @@ export interface GetScriptDisplayNamesResponse {
refs: Record<string, string | null>;
/** Ref tokens from the request that failed to parse, or null when there were none — the key is omitted when null (the empty-body and empty-refs early-exit sites never wrote this key at all; they leave it null). */
malformed?: string[];
/** True when the lookup ran into one of its time limits before every ref had an answer, so asking again can return more names. Null otherwise — the key is omitted. A failed Graph call does not set it. */
timedOut?: boolean;
}
/** Response of GET sessions/{sessionId}/annotations: the annotation lanes visible to the caller plus the server-computed list of lanes the caller may write. */
@@ -4755,6 +4757,8 @@ export interface SaveNotificationEmailRequest {
/** Body of POST tenants/{tenantId}/scripts/display-names: Graph script references to resolve. */
export interface ScriptDisplayNamesRequest {
refs?: string[];
/** True on the one follow-up lookup a client sends after a response with timedOut. The lookup itself runs the same; the flag only labels the request. */
retry?: boolean | null;
}
/** SearchSessions envelope. Sessions carries full SessionSummary items, or dictionary projections of them when the caller passed a fields= subset. */
@@ -85,10 +85,20 @@ function pickString(d: Record<string, unknown>, keys: string[]): string | undefi
/** Backend's per-request cap on ref count (mirrors `MaxRefsPerRequest` in the C# function). */
export const SCRIPT_DISPLAY_NAMES_CHUNK_SIZE = 200;
/** How long the portal waits before its one follow-up lookup. */
export const SCRIPT_NAMES_FOLLOW_UP_DELAY_MS = 5_000;
/** One answer of the backend: the names it has, and whether a time limit cut the lookup short. */
export interface ScriptNamesAnswer {
names: DisplayNamesByRefKey;
timedOut: boolean;
}
/**
* POSTs the resolve request to the backend. Returns a map keyed by the canonical
* `Kind:id` form. Returns an empty map on transport / non-2xx errors so callers can
* fall back to "show the ID" without surfacing an error to the user.
* POSTs the resolve request to the backend. `names` is keyed by the canonical `Kind:id`
* form and stays empty on transport / non-2xx errors, so callers can fall back to "show
* the ID" without surfacing an error to the user. `timedOut` repeats the backend's flag:
* a time limit cut the lookup short, and asking again can return more names.
*
* Refs above {@link SCRIPT_DISPLAY_NAMES_CHUNK_SIZE} are split into batches and POSTed
* sequentially -- the first batch warms the backend cache for the tenant so subsequent
@@ -100,11 +110,13 @@ export async function fetchScriptDisplayNames(
tenantId: string,
refs: ScriptRef[],
getAccessToken: GetAccessToken,
): Promise<DisplayNamesByRefKey> {
if (refs.length === 0) return {};
isFollowUp = false,
): Promise<ScriptNamesAnswer> {
const merged: DisplayNamesByRefKey = {};
let timedOut = false;
if (refs.length === 0) return { names: merged, timedOut };
const url = api.graphPermissions.scriptDisplayNames(tenantId);
const merged: DisplayNamesByRefKey = {};
for (let i = 0; i < refs.length; i += SCRIPT_DISPLAY_NAMES_CHUNK_SIZE) {
const chunk = refs.slice(i, i + SCRIPT_DISPLAY_NAMES_CHUNK_SIZE);
@@ -112,11 +124,15 @@ export async function fetchScriptDisplayNames(
try {
const body = await fetchJson<GetScriptDisplayNamesResponse>(url, getAccessToken, {
method: "POST",
body: jsonBody<ScriptDisplayNamesRequest>({ refs: chunk.map(formatRefKey) }),
body: jsonBody<ScriptDisplayNamesRequest>({
refs: chunk.map(formatRefKey),
...(isFollowUp ? { retry: true } : {}),
}),
});
if (body.refs) {
Object.assign(merged, body.refs);
}
if (body.timedOut === true) timedOut = true;
} catch (err) {
// Auth signal MUST propagate so the hook can short-circuit and the app's auth flow
// can react (token refresh / sign-in redirect). Any partial map collected so far is
@@ -130,14 +146,78 @@ export async function fetchScriptDisplayNames(
}
}
return { names: merged, timedOut };
}
/**
* The names after a follow-up lookup: every name the follow-up resolved, plus every name
* only the first answer had. A follow-up can itself be cut short (at the token, say) and
* then knows less than the first answer; it must never take away a name already on screen.
*/
export function mergeFollowUpNames(
first: DisplayNamesByRefKey,
followUp: DisplayNamesByRefKey,
): DisplayNamesByRefKey {
const merged: DisplayNamesByRefKey = { ...first };
for (const [key, name] of Object.entries(followUp)) {
if (name != null || !(key in merged)) merged[key] = name;
}
return merged;
}
/**
* Runs one lookup and, when the backend reports that a time limit cut it short, exactly
* one follow-up after `wait`. `publish` receives the names after each answer. A follow-up
* that is cut short as well ends the attempt: a backend that stays slow gains nothing from
* more requests. `isCancelled` is read after every await, so a lookup whose question is no
* longer current publishes nothing and sends no follow-up.
*/
export async function lookUpWithOneFollowUp(
ask: (isFollowUp: boolean) => Promise<ScriptNamesAnswer>,
publish: (names: DisplayNamesByRefKey) => void,
wait: () => Promise<void>,
isCancelled: () => boolean,
): Promise<void> {
const first = await ask(false);
if (isCancelled()) return;
publish(first.names);
if (!first.timedOut) return;
await wait();
if (isCancelled()) return;
const followUp = await ask(true);
if (isCancelled()) return;
publish(mergeFollowUpNames(first.names, followUp.names));
}
/**
* The key a stored answer must carry to be served, and "" while no lookup may run: no
* tenant, no script refs, or the caller is not `ready` yet. It covers both staleness cases:
* (a) Tenant changed: previous tenant's names must not linger on new tenant's rows.
* (b) Refset changed to empty (events not yet loaded, or filtered to none): we have
* no authoritative data anymore, so the previous map is stale.
*/
export function scriptNamesLookupKey(
tenantId: string | null | undefined,
refsKey: string,
ready: boolean,
): string {
return ready && tenantId && refsKey.length > 0 ? `${tenantId}::${refsKey}` : "";
}
/**
* React hook: collects refs from `events`, fires a single backend lookup, and exposes
* a lookup map keyed by the canonical "{Kind}:{Id}" form. Same id under different kinds
* stays distinct -- callers must always supply both kind and id when looking up.
*
* `ready` holds the lookup back until the caller's event list is complete. The session
* page loads events page by page; without the gate every page that adds a script fires
* its own lookup for the growing set, and on a cold backend cache each of them pulls the
* tenant's whole script list from Graph.
*
* When the backend reports that a time limit cut the lookup short, the hook asks once more
* after {@link SCRIPT_NAMES_FOLLOW_UP_DELAY_MS}; see {@link lookUpWithOneFollowUp}.
*
* Stale-state protection: the hook keys its effect off a STABLE refset-fingerprint
* (`refsKey`) instead of the raw `events` array identity, which the parent typically
* re-creates on every render via `.filter()`. The fetched map is stored TOGETHER with
@@ -149,6 +229,7 @@ export function useScriptDisplayNames(
tenantId: string | null | undefined,
events: readonly { eventType?: string; data?: unknown }[],
getAccessToken: GetAccessToken,
ready: boolean,
): DisplayNamesByRefKey {
const [loaded, setLoaded] = useState<{ key: string; map: DisplayNamesByRefKey }>({
key: "",
@@ -165,14 +246,10 @@ export function useScriptDisplayNames(
[refs],
);
// The key a stored map must answer to be served. Covers both staleness cases:
// (a) Tenant changed: previous tenant's names must not linger on new tenant's rows.
// (b) Refset changed to empty (events not yet loaded, or filtered to none): we have
// no authoritative data anymore, so the previous map is stale.
const lookupKey = tenantId && refsKey.length > 0 ? `${tenantId}::${refsKey}` : "";
const lookupKey = scriptNamesLookupKey(tenantId, refsKey, ready);
useEffect(() => {
if (!tenantId || refsKey.length === 0) return;
if (!lookupKey || !tenantId) return;
// Reconstruct refs from the stable key so this effect doesn't depend on the
// `refs` array identity (which is already covered transitively via refsKey).
@@ -184,19 +261,22 @@ export function useScriptDisplayNames(
});
let cancelled = false;
(async () => {
try {
const refsMap = await fetchScriptDisplayNames(tenantId, stableRefs, getAccessToken);
if (cancelled) return;
setLoaded({ key: `${tenantId}::${refsKey}`, map: refsMap });
} catch (err) {
if (err instanceof TokenExpiredError) return; // user signed out / token expired
console.warn("useScriptDisplayNames: lookup failed", err);
}
})();
let followUpTimer: ReturnType<typeof setTimeout> | undefined;
lookUpWithOneFollowUp(
isFollowUp => fetchScriptDisplayNames(tenantId, stableRefs, getAccessToken, isFollowUp),
names => setLoaded({ key: lookupKey, map: names }),
() => new Promise<void>(resolve => { followUpTimer = setTimeout(resolve, SCRIPT_NAMES_FOLLOW_UP_DELAY_MS); }),
() => cancelled,
).catch(err => {
if (err instanceof TokenExpiredError) return; // user signed out / token expired
console.warn("useScriptDisplayNames: lookup failed", err);
});
return () => { cancelled = true; };
}, [tenantId, refsKey, getAccessToken]);
return () => {
cancelled = true;
clearTimeout(followUpTimer);
};
}, [lookupKey, tenantId, refsKey, getAccessToken]);
return loaded.key === lookupKey && lookupKey ? loaded.map : EMPTY_DISPLAY_NAMES;
}
@@ -12,6 +12,11 @@ import type { TenantConfigFieldName } from "@/lib/tenantConfigSave";
export const SETUP_STEPS = ["settings", "hardware", "validation", "deploy"] as const;
export type SetupStepId = (typeof SETUP_STEPS)[number];
/** The step a `?setup-guide=` link names, or null. */
export function parseSetupStep(raw: string | null | undefined): SetupStepId | null {
return (SETUP_STEPS as readonly string[]).includes(raw ?? "") ? (raw as SetupStepId) : null;
}
/** The feature-flag fields the checklist reads. */
export type SetupGuideFlags = Pick<
TenantFeatureFlagsResponse,
@@ -19,8 +24,8 @@ export type SetupGuideFlags = Pick<
>;
/**
* What this browser learned ahead of the flags. The backend caches a tenant's configuration per
* instance for a few minutes, so a step the admin just finished could otherwise show as open again.
* What this browser learned ahead of the flags: a step the admin just finished shows as done at
* once, and the first session counts before the tenant's enrollment counter has it.
*/
export interface SetupGuideLocalMarks {
settings?: boolean;
@@ -8,12 +8,16 @@ import { patchTenantConfigFields } from "@/lib/tenantConfigSave";
/** The steps the setup-guide endpoint records. */
export type SetupGuideMarker = "settings" | "hardware" | "dismiss" | "restore";
/** POST config/{tenantId}/setup-guide: the server stamps time and UPN. */
/**
* POST config/{tenantId}/setup-guide: the server stamps time and UPN. The markers are part of the
* feature flags, so the cached flags go like after any other configuration write.
*/
export async function recordSetupGuideStep(tenantId: string, step: SetupGuideMarker, getAccessToken: GetAccessToken): Promise<void> {
await fetchOk(api.config.setupGuide(tenantId), getAccessToken, {
method: "POST",
body: jsonBody<SetupGuideStepRequest>({ step }),
});
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
}
/**
@@ -0,0 +1,70 @@
/**
* After this browser wrote a tenant configuration, its requests ask the backend for the stored row
* for a few minutes (`X-Tenant-Config: stored`).
*
* The backend caches each tenant's configuration per instance, and a write clears only the cache of
* the instance that served it. The next read is often answered by another instance: without the
* header it shows the configuration from before the write (a settings page reloaded after the
* admin-consent redirect, "Send test" right after saving a channel, auth/me after the terms tick).
*
* The mark lives in localStorage, because it has to survive that redirect and reach other tabs.
*/
const STORAGE_KEY = "am_config_written";
/**
* Longer than the backend's configuration cache (TenantConfigurationService.CacheDuration, five
* minutes): once it has passed, no instance holds a copy from before the write. Raising the
* backend's duration without this one brings the stale reads back.
*/
export const STORED_CONFIG_HINT_MS = 6 * 60_000;
const HEADERS = { "X-Tenant-Config": "stored" } as const;
const NO_HEADERS = {} as const;
/** The last write this tab made, for a browser without usable storage. */
let lastWriteAt = 0;
function readMark(): number {
try {
const stored = Number(window.localStorage.getItem(STORAGE_KEY));
return Number.isFinite(stored) ? Math.max(stored, lastWriteAt) : lastWriteAt;
} catch {
return lastWriteAt;
}
}
/**
* A request that writes a tenant configuration row: every write under `/api/config/`, and the terms
* confirmation. The backend pins this list from its side (TenantConfigWriteRouteGuardTests): a route
* that writes the row anywhere else has to be added here.
*/
export function writesTenantConfig(method: string, url: string): boolean {
const verb = method.toUpperCase();
if (verb === "GET" || verb === "HEAD") return false;
let path = url;
try {
path = new URL(url, "http://localhost").pathname;
} catch {
// Keep the raw value: a relative path still matches below.
}
return path.startsWith("/api/config/") || path === "/api/auth/dpa-acceptance";
}
/** Call once a configuration write has succeeded. */
export function noteTenantConfigWritten(now: number = Date.now()): void {
lastWriteAt = now;
try {
window.localStorage.setItem(STORAGE_KEY, String(now));
} catch {
// Without storage the mark still covers this tab until it reloads.
}
}
/** The header every API call carries while a configuration write of this browser is recent; none otherwise. */
export function storedConfigHeaders(now: number = Date.now()): Readonly<Record<string, string>> {
if (typeof window === "undefined") return NO_HEADERS;
const at = readMark();
// A mark from the future (the device clock was set back) counts no longer than a fresh one.
return at > 0 && Math.abs(now - at) < STORED_CONFIG_HINT_MS ? HEADERS : NO_HEADERS;
}
-1
View File
@@ -71,7 +71,6 @@
"ea1ca3e9431f1e55": 1,
"ecb287fdcc477953": 1,
"efa68808a3272d86": 1,
"f10773ba54670c55": 1,
"f856781d2949e84a": 1,
"fc4e522bd78e5b29": 1,
"fd1dfc51b77dc41b": 1