diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
index c9ce595d..cbca8db9 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
@@ -44,6 +44,9 @@ public class GetTenantFeatureFlagsPayloadTests
// Non-sensitive by review: bool only ("is a contact address set"), never the
// address itself (admin-gated full config only).
"contactEmailSet",
+ // Non-sensitive by review: bool only ("is any device validation method enabled"),
+ // never which one or any Graph detail.
+ "deviceValidationEnabled",
"diagnosticsUploadConfigured",
"edition",
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
@@ -59,7 +62,6 @@ public class GetTenantFeatureFlagsPayloadTests
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
- "validateAutopilotDevice",
}, fieldNames);
}
@@ -81,13 +83,30 @@ public class GetTenantFeatureFlagsPayloadTests
var element = Serialize(config);
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
- Assert.True(element.GetProperty("validateAutopilotDevice").GetBoolean());
+ Assert.True(element.GetProperty("deviceValidationEnabled").GetBoolean());
Assert.False(element.GetProperty("showScriptOutput").GetBoolean());
Assert.True(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean());
Assert.False(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean());
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(true, false, true)]
+ [InlineData(false, true, true)]
+ public void Payload_DeviceValidationEnabled_IsAnyMethod_NotJustAutopilot(bool autopilot, bool intuneEnrollment, bool expected)
+ {
+ // A device-preparation tenant that validates only via Intune enrollment (or device
+ // association) must not see the "agent ingestion is blocked" banner.
+ var element = Serialize(new TenantConfiguration
+ {
+ ValidateAutopilotDevice = autopilot,
+ ValidateIntuneDeviceBinding = intuneEnrollment,
+ });
+
+ Assert.Equal(expected, element.GetProperty("deviceValidationEnabled").GetBoolean());
+ }
+
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs
index 86126fe2..43472e3e 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs
@@ -162,6 +162,7 @@ public class IntuneDeviceBindingValidatorTests
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
+ [InlineData(IntuneDeviceBindingOutcome.NotManaged)]
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
{
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneEnrollmentValidationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneEnrollmentValidationTests.cs
new file mode 100644
index 00000000..f9d0dc73
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneEnrollmentValidationTests.cs
@@ -0,0 +1,535 @@
+using System.Net;
+using System.Text.Json;
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.GraphResolution;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Graph;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Intune Enrollment Validation: the certificate's Intune device id admits a device without any
+/// pre-registration (), and observes
+/// devices another validator admitted ()
+/// — the latter only when the tenant granted the Graph permission, so a tenant without the add-on
+/// costs no Graph call at all.
+///
+public class IntuneEnrollmentValidationTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
+
+ private static readonly DateTimeOffset OldCert = DateTimeOffset.UtcNow.AddDays(-30);
+ private static readonly DateTimeOffset RecentCert = DateTimeOffset.UtcNow.AddMinutes(-5);
+
+ private const string MatchBody =
+ "{\"id\":\"07bc0167-9061-43b7-b243-0bb6aeda736e\",\"deviceName\":\"TEST-DEVICE-01\",\"managementState\":\"managed\","
+ + "\"serialNumber\":\"TESTSERIAL01\",\"managedDeviceOwnerType\":\"company\",\"deviceEnrollmentType\":\"windowsAzureADJoin\"}";
+
+ // ------------------------------------------------------------------ harness
+
+ private sealed class ScriptedHandler : HttpMessageHandler
+ {
+ private readonly Queue> _responses;
+ public List Requests { get; } = new();
+
+ public ScriptedHandler(params Func[] responses) => _responses = new(responses);
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ lock (Requests) Requests.Add(request);
+ var next = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
+ return Task.FromResult(next());
+ }
+ }
+
+ private static Func Respond(HttpStatusCode status, string body = "{}")
+ => () => new HttpResponseMessage(status) { Content = new StringContent(body) };
+
+ private sealed record Sut(
+ IntuneDeviceBindingValidator Validator,
+ ScriptedHandler Handler,
+ MemoryCache Cache,
+ Mock Tokens,
+ Mock Detector);
+
+ private static Sut Build(bool permissionGranted, params Func[] responses)
+ {
+ var handler = new ScriptedHandler(responses.Length == 0 ? new[] { Respond(HttpStatusCode.OK, MatchBody) } : responses);
+ var factory = new Mock();
+ factory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => new HttpClient(handler, disposeHandler: false));
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
+ ["EntraId:ClientSecret"] = "secret",
+ }).Build();
+ var registry = new EntraAppRegistry(configuration, NullLogger.Instance);
+ var tenantConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, cache)
+ { CallBase = false };
+ var tokens = new Mock(
+ NullLogger.Instance, Mock.Of(), cache,
+ configuration, registry, tenantConfig.Object)
+ { CallBase = false };
+ tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny()))
+ .ReturnsAsync(GraphTokenResult.Success("token"));
+
+ var detector = new Mock();
+ detector.Setup(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny()))
+ .ReturnsAsync(permissionGranted);
+
+ var validator = new IntuneDeviceBindingValidator(
+ NullLogger.Instance, factory.Object, cache, tokens.Object, detector.Object);
+ return new Sut(validator, handler, cache, tokens, detector);
+ }
+
+ private static async Task WaitUntil(Func condition)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (!condition())
+ {
+ if (DateTime.UtcNow > deadline)
+ throw new TimeoutException("Background observation did not settle in time.");
+ await Task.Delay(20);
+ }
+ }
+
+ private static bool InFlight(MemoryCache cache)
+ => cache.TryGetValue(IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId) + ":inflight", out _);
+
+ // ------------------------------------------------------------------ admitting lookups
+
+ [Fact]
+ public async Task Admitting_Match_IsCached_SoTheNextRequestCostsNoGraphCall()
+ {
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
+
+ var first = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+ var second = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.Match, first.Outcome);
+ Assert.False(first.ServedFromCache);
+ Assert.True(second.ServedFromCache);
+ Assert.Single(sut.Handler.Requests);
+ Assert.Equal("TESTSERIAL01", second.SerialNumber);
+ }
+
+ [Fact]
+ public async Task Admitting_UsesAPointGetWithTheTelemetrySelect()
+ {
+ var sut = Build(permissionGranted: true);
+
+ await sut.Validator.ValidateAsync(TenantId, DeviceId.ToUpperInvariant(), OldCert, IntuneDeviceBindingRole.Admitting);
+
+ var uri = Uri.UnescapeDataString(Assert.Single(sut.Handler.Requests).RequestUri!.ToString());
+ Assert.Contains($"/v1.0/deviceManagement/managedDevices/{DeviceId}?", uri);
+ Assert.DoesNotContain("$filter", uri);
+ foreach (var field in new[] { "managementState", "serialNumber", "managedDeviceOwnerType", "deviceEnrollmentType", "azureADRegistered" })
+ Assert.Contains(field, uri);
+ }
+
+ [Fact]
+ public async Task Admitting_NotFound_WithAnOldCertificate_IsDefinitiveAndCached()
+ {
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
+
+ var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
+ Assert.Single(sut.Handler.Requests);
+ Assert.Equal(IntuneDeviceBindingOutcome.NotFound, sut.Validator.TryGetCached(TenantId, DeviceId)?.Outcome);
+ }
+
+ [Fact]
+ public async Task Admitting_NotFound_WithARecentCertificate_IsTransientAndNeverCached()
+ {
+ // The enrollment race: the device may not be visible in Graph yet. Caching the 404 would
+ // keep a genuine device out for the whole negative TTL.
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
+
+ var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, result.Outcome);
+ Assert.True(result.IsTransient);
+ Assert.Equal(2, sut.Handler.Requests.Count);
+ Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
+ }
+
+ [Fact]
+ public async Task Admitting_RecentCertificate_ThenTheObjectAppears_IsAdmittedOnTheRetry()
+ {
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound), Respond(HttpStatusCode.OK, MatchBody));
+
+ var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
+ Assert.Equal(2, sut.Handler.Requests.Count);
+ }
+
+ [Fact]
+ public async Task Admitting_ForbiddenWithAFreshTokenToo_IsPermissionMissing()
+ {
+ var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.Forbidden));
+
+ var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.PermissionMissing, result.Outcome);
+ sut.Tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
+ }
+
+ [Fact]
+ public async Task Admitting_RunsEvenWhenThePermissionWasNotDetected()
+ {
+ // An admin who just ran the grant script must not wait for the detector's token cache:
+ // the admitting lookup goes to Graph and the 401/403 refresh picks up the new role.
+ var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.OK, MatchBody));
+
+ var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
+ sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Admitting_InvalidDeviceId_NeverReachesGraph()
+ {
+ var sut = Build(permissionGranted: true);
+
+ var result = await sut.Validator.ValidateAsync(TenantId, "not-a-guid", OldCert, IntuneDeviceBindingRole.Admitting);
+
+ Assert.Equal(IntuneDeviceBindingOutcome.NoDeviceIdInCert, result.Outcome);
+ Assert.Empty(sut.Handler.Requests);
+ }
+
+ // ------------------------------------------------------------------ observation
+
+ [Fact]
+ public async Task Observe_WithoutThePermission_MakesNoGraphCall_AndAsksTheDetectorOncePerWindow()
+ {
+ var sut = Build(permissionGranted: false);
+
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
+ await WaitUntil(() => !InFlight(sut.Cache));
+
+ // Later requests of the same tenant stop at the cached "not granted" verdict.
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
+ sut.Validator.ObserveInBackground(TenantId, "22222222-3333-4444-5555-666666666666", OldCert, null);
+ await WaitUntil(() => !InFlight(sut.Cache));
+
+ Assert.Empty(sut.Handler.Requests);
+ sut.Tokens.Verify(t => t.GetAccessTokenAsync(It.IsAny(), It.IsAny()), Times.Never);
+ sut.Detector.Verify(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny()), Times.Once);
+ Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
+ }
+
+ [Fact]
+ public async Task Observe_WithThePermission_FillsTheCacheInTheBackground()
+ {
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
+
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, "session-1");
+ await WaitUntil(() => sut.Validator.TryGetCached(TenantId, DeviceId) != null);
+
+ var observed = sut.Validator.TryGetCached(TenantId, DeviceId)!;
+ Assert.Equal(IntuneDeviceBindingOutcome.Match, observed.Outcome);
+ Assert.True(observed.ServedFromCache);
+ Assert.Single(sut.Handler.Requests);
+ }
+
+ [Fact]
+ public async Task Observe_WhenAlreadyCached_DoesNothing()
+ {
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
+ await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
+
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
+
+ Assert.False(InFlight(sut.Cache));
+ Assert.Single(sut.Handler.Requests);
+ sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Observe_TransientOutcome_KeepsACooldownInsteadOfALookupPerRequest()
+ {
+ // A recent certificate whose device is not visible yet caches nothing; without the cooldown
+ // every request of that device would start a new Graph lookup.
+ var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
+
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
+ await WaitUntil(() => sut.Handler.Requests.Count == 2);
+ await Task.Delay(100);
+
+ sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
+ await Task.Delay(100);
+
+ Assert.True(InFlight(sut.Cache));
+ Assert.Equal(2, sut.Handler.Requests.Count);
+ Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
+ }
+
+ [Fact]
+ public void Observe_InvalidDeviceId_DoesNothing()
+ {
+ var sut = Build(permissionGranted: true);
+
+ sut.Validator.ObserveInBackground(TenantId, null, OldCert, null);
+
+ sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ // ------------------------------------------------------------------ SecurityValidator chain
+
+ private static (SecurityValidator Validator, Mock Binding) BuildChain(TenantConfiguration config)
+ {
+ var configRepo = Mock.Of();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ config.ManufacturerWhitelist = "*";
+ config.ModelWhitelist = "*";
+
+ var configService = new Mock(
+ configRepo, Mock.Of>(), cache)
+ { CallBase = false };
+ configService.Setup(x => x.TryGetConfigurationAsync(It.IsAny())).ReturnsAsync((config, true));
+
+ var adminConfigService = new Mock(
+ configRepo, Mock.Of>(), cache)
+ { CallBase = false };
+ adminConfigService.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
+
+ var binding = new Mock(
+ NullLogger.Instance, Mock.Of(), cache,
+ null!, Mock.Of())
+ { CallBase = false };
+
+ var validator = new SecurityValidator(
+ configService.Object, adminConfigService.Object,
+ new RateLimitService(cache, Mock.Of>()),
+ autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
+ NullLogger.Instance,
+ intuneDeviceBindingValidator: binding.Object);
+ return (validator, binding);
+ }
+
+ private static string CertTenant => CertTenantBindingTests.SampleCertTenantId.ToString();
+
+ private static void SetupAdmitting(Mock binding, IntuneDeviceBindingResult result)
+ => binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny(), IntuneDeviceBindingRole.Admitting,
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(result);
+
+ [Fact]
+ public async Task Chain_OnlyIntuneEnrollmentEnabled_PassesTheGate_AndAdmitsAnEnrolledDevice()
+ {
+ var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
+ SetupAdmitting(binding, new IntuneDeviceBindingResult
+ {
+ Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId,
+ SerialNumber = "TESTSERIAL01", OwnerType = "personal", EnrollmentType = "windowsAzureADJoin",
+ });
+ var items = new Dictionary