diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs new file mode 100644 index 00000000..ba224dfc --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Graph contract tests for . WDP regression +/// (2026-08-17, first live Device Preparation enrollment): admins following the Device +/// Preparation guidance upload SERIAL-type corporate identifiers, but the validator only +/// searched the manufacturerModelSerial type — every WDP device 403'd as "not registered". +/// The validator must probe both identifier types in a single searchExistingIdentities call. +/// +public class CorporateIdentifierValidatorTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string Serial = "7801-5131-4473-3387-5637-4002-18"; + + private sealed class CapturingHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + public string? LastRequestBody { get; private set; } + + public CapturingHandler(HttpResponseMessage response) => _response = response; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(ct); + return _response; + } + } + + private static (CorporateIdentifierValidator Sut, CapturingHandler Handler) BuildSut( + string responseJson, HttpStatusCode status = HttpStatusCode.OK) + { + var handler = new CapturingHandler(new HttpResponseMessage(status) + { + Content = new StringContent(responseJson), + }); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(new HttpClient(handler)); + + 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 tokenService = new Mock( + NullLogger.Instance, Mock.Of(), cache, + configuration, registry, tenantConfig.Object) + { CallBase = false }; + tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny())) + .ReturnsAsync(GraphTokenResult.Success("token")); + + var sut = new CorporateIdentifierValidator( + NullLogger.Instance, factory.Object, cache, tokenService.Object); + return (sut, handler); + } + + [Fact] + public async Task ValidateAsync_sendsBothIdentifierTypes_inOneSearchCall() + { + var (sut, handler) = BuildSut("""{"value":[]}"""); + + await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.NotNull(handler.LastRequestBody); + var candidates = (JArray)JObject.Parse(handler.LastRequestBody!)["importedDeviceIdentities"]!; + Assert.Collection(candidates, + mms => + { + Assert.Equal("manufacturerModelSerial", mms["importedDeviceIdentityType"]?.ToString()); + Assert.Equal($"Microsoft Corporation,Virtual Machine,{Serial}", mms["importedDeviceIdentifier"]?.ToString()); + }, + serialOnly => + { + Assert.Equal("serialNumber", serialOnly["importedDeviceIdentityType"]?.ToString()); + Assert.Equal(Serial, serialOnly["importedDeviceIdentifier"]?.ToString()); + }); + } + + [Fact] + public async Task ValidateAsync_serialNumberTypeMatch_isValid() + { + // The WDP case: only a serial-type corporate identifier exists in Intune. + var (sut, _) = BuildSut($$""" + {"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"{{Serial}}"}]} + """); + + var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.True(result.IsValid); + Assert.False(result.IsTransient); + Assert.Equal(Serial, result.Identifier); + } + + [Fact] + public async Task ValidateAsync_manufacturerModelSerialMatch_staysValid() + { + var identifier = $"Microsoft Corporation,Virtual Machine,{Serial}"; + var (sut, _) = BuildSut($$""" + {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{identifier}}"}]} + """); + + var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.True(result.IsValid); + Assert.Equal(identifier, result.Identifier); + } + + [Fact] + public async Task ValidateAsync_noMatch_isDefinitive_andNamesBothSearchedForms() + { + var (sut, _) = BuildSut("""{"value":[]}"""); + + var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + // The message must expose both searched identifier forms so a 403 is diagnosable + // from the rejection log alone (the 2026-08-17 WDP session was not). + Assert.Contains($"Microsoft Corporation,Virtual Machine,{Serial}", result.ErrorMessage); + Assert.Contains($"serialNumber '{Serial}'", result.ErrorMessage); + } + + [Fact] + public async Task ValidateAsync_graphError_isTransient() + { + var (sut, _) = BuildSut("""{"error":{"code":"InternalServerError"}}""", HttpStatusCode.InternalServerError); + + var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.False(result.IsValid); + Assert.True(result.IsTransient); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs index aafa4d9b..86bcf663 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs @@ -141,4 +141,28 @@ public class SecurityValidatorTests Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject( "CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _)); } + + // --- Device-validation error aggregation --- + // With several validators enabled, the 403 Details used to carry only the LAST failure + // (CloudPc), hiding why the Autopilot / CorporateIdentifier lookups missed — the exact + // trap during the first live WDP enrollment (2026-08-17). + + [Fact] + public void CombineValidationErrors_joinsInChainOrder() + { + var combined = SecurityValidator.CombineValidationErrors("autopilot miss", "corp miss"); + combined = SecurityValidator.CombineValidationErrors(combined, "cloudpc miss"); + + Assert.Equal("autopilot miss | corp miss | cloudpc miss", combined); + } + + [Theory] + [InlineData(null, "only", "only")] + [InlineData("only", null, "only")] + [InlineData(null, null, null)] + [InlineData("", "only", "only")] + public void CombineValidationErrors_handlesMissingHalves(string? a, string? b, string? expected) + { + Assert.Equal(expected, SecurityValidator.CombineValidationErrors(a, b)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs index 115131cd..ad249aac 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs @@ -13,7 +13,10 @@ namespace AutopilotMonitor.Functions.Security { /// /// Validates devices against Intune Corporate Device Identifiers via Microsoft Graph beta API. - /// Uses the importedDeviceIdentities/searchExistingIdentities endpoint with manufacturerModelSerial type. + /// Uses the importedDeviceIdentities/searchExistingIdentities endpoint and probes BOTH + /// identifier types an admin can upload: manufacturerModelSerial and serialNumber. The + /// serial-only type is the one the Autopilot Device Preparation (WDP) guidance uses, so + /// without it every WDP device whose admin followed the Microsoft docs fails validation. /// Caches positive/negative lookups to reduce Graph traffic. /// public class CorporateIdentifierValidator @@ -115,6 +118,8 @@ namespace AutopilotMonitor.Functions.Security graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); // POST https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities + // Two candidates in one round-trip: admins upload corporate identifiers either as + // "manufacturer,model,serial" or (per the WDP guidance) as bare serial number. var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}"; var requestBody = new { @@ -124,6 +129,11 @@ namespace AutopilotMonitor.Functions.Security { importedDeviceIdentityType = "manufacturerModelSerial", importedDeviceIdentifier = identifier + }, + new + { + importedDeviceIdentityType = "serialNumber", + importedDeviceIdentifier = normalizedSerial } } }; @@ -161,21 +171,24 @@ namespace AutopilotMonitor.Functions.Security return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult { IsValid = false, - ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier" + ErrorMessage = $"Device is not registered as a Corporate Identifier (searched manufacturerModelSerial '{identifier}' and serialNumber '{normalizedSerial}')" }, isPositive: false); } + var matchedIdentifier = identities[0]?["importedDeviceIdentifier"]?.ToString() ?? identifier; + var matchedType = identities[0]?["importedDeviceIdentityType"]?.ToString() ?? "manufacturerModelSerial"; var result = new CorporateIdentifierValidationResult { IsValid = true, - Identifier = identifier + Identifier = matchedIdentifier }; _logger.LogInformation( - "Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}", + "Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier} (type {IdentifierType})", tenantId, sessionId ?? "", - identifier); + matchedIdentifier, + matchedType); return CacheAndReturn(cacheKey, result, isPositive: true); } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 16c7c4d6..8b1e9917 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -362,8 +362,13 @@ namespace AutopilotMonitor.Functions.Security } else { - deviceValidationError = corpResult.ErrorMessage; - deviceValidationTransient = corpResult.IsTransient; + // Accumulate instead of overwrite: with several validators enabled, only the + // LAST failure used to surface in the 403 Details / rejection log, hiding why + // the earlier (usually more relevant) lookups missed. Transient is sticky-OR: + // if ANY validator failed transiently the device might actually be authorized, + // so the caller must return 503 Retry-After rather than a definitive 403. + deviceValidationError = CombineValidationErrors(deviceValidationError, corpResult.ErrorMessage); + deviceValidationTransient |= corpResult.IsTransient; } } @@ -384,8 +389,8 @@ namespace AutopilotMonitor.Functions.Security } else { - deviceValidationError = cloudPcResult.ErrorMessage; - deviceValidationTransient = cloudPcResult.IsTransient; + deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage); + deviceValidationTransient |= cloudPcResult.IsTransient; } } @@ -466,6 +471,18 @@ namespace AutopilotMonitor.Functions.Security }; } + /// + /// Joins the per-validator failure messages so the 403 Details / rejection log carries + /// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the + /// last validator's. + /// + internal static string? CombineValidationErrors(string? accumulated, string? next) + { + if (string.IsNullOrEmpty(next)) return accumulated; + if (string.IsNullOrEmpty(accumulated)) return next; + return accumulated + " | " + next; + } + /// /// Extracts the Intune device id from an MDM client certificate subject. Certs issued by /// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN