mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 20:51:56 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01e7abc2b | |||
| 512b145484 | |||
| 8ece30631a | |||
| c9078d00b6 | |||
| 1ff68dc0af | |||
| 7c8204357b | |||
| dfcf7ad8ba | |||
| 54d4c0bd4c | |||
| 11bc0ec2ab | |||
| 7e9b0cea54 | |||
| c115cb354e | |||
| 9cfe3fb74d | |||
| 892ff4c1e1 | |||
| 2cb181734a |
+1
-1
Submodule .claude/commands updated: 3699da8230...7fee9d0e6e
+1
-1
Submodule internal updated: e0913c34d2...e6a23a2a1f
+23
@@ -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()
|
||||
{
|
||||
|
||||
+167
@@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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()
|
||||
{
|
||||
|
||||
+31
@@ -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]);
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -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>
|
||||
|
||||
+165
-17
@@ -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
|
||||
|
||||
+8
-7
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
+116
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
|
||||
+1
-2
@@ -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) };
|
||||
}
|
||||
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
-2
@@ -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(
|
||||
|
||||
+1
-2
@@ -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);
|
||||
|
||||
+1
-2
@@ -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");
|
||||
}
|
||||
|
||||
+1
-2
@@ -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");
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+24
@@ -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.
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
+8
-5
@@ -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 * * *";
|
||||
|
||||
+5
-4
@@ -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
-4
@@ -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);
|
||||
|
||||
+93
-20
@@ -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;
|
||||
}
|
||||
@@ -71,7 +71,6 @@
|
||||
"ea1ca3e9431f1e55": 1,
|
||||
"ecb287fdcc477953": 1,
|
||||
"efa68808a3272d86": 1,
|
||||
"f10773ba54670c55": 1,
|
||||
"f856781d2949e84a": 1,
|
||||
"fc4e522bd78e5b29": 1,
|
||||
"fd1dfc51b77dc41b": 1
|
||||
|
||||
Reference in New Issue
Block a user