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