Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs
T
Oliver Kieselbach 7fbfea8f4e Corp identifier validation: probe serialNumber type + unmask validator errors
First live WDP enrollment 403d at device validation. Root causes:

- CorporateIdentifierValidator only searched the manufacturerModelSerial
  identifier type, but the Device Preparation guidance has admins upload
  SERIAL-type corporate identifiers - those never matched. The Graph
  searchExistingIdentities call now probes both types in one round-trip.
- SecurityValidator overwrote deviceValidationError per validator, so the
  403 Details only showed the LAST failure (CloudPc) and hid the corporate
  identifier miss. Errors now aggregate in chain order and the transient
  flag is sticky-OR (any transient failure yields 503 Retry-After instead
  of a definitive 403).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:41:57 +02:00

169 lines
6.5 KiB
C#

using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for SecurityValidator GUID validation.
/// StoreEventsBatchAsync relies on EnsureValidGuid to reject invalid TenantId/SessionId values —
/// these tests guard that contract.
/// </summary>
public class SecurityValidatorTests
{
// --- IsValidGuid ---
[Theory]
// Valid: hyphenated 8-4-4-4-12, any casing.
[InlineData("a1b2c3d4-e5f6-7890-abcd-ef1234567890", true)] // lowercase
[InlineData("A1B2C3D4-E5F6-7890-ABCD-EF1234567890", true)] // uppercase
[InlineData("a1B2c3D4-e5F6-7890-AbCd-Ef1234567890", true)] // mixed case
// Invalid: wrong/absent format.
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)] // whitespace only
[InlineData("not-a-guid", false)]
[InlineData(@"DESKTOP-DIU8038\defaultuser0", false)] // regression: agent-as-defaultuser0 sent this as TenantId
[InlineData("a1b2c3d4e5f67890abcdef1234567890", false)] // no dashes → not standard format
[InlineData("{a1b2c3d4-e5f6-7890-abcd-ef1234567890}", false)] // braced {…} not accepted
public void IsValidGuid_reflects_guid_format(string? input, bool expected)
{
Assert.Equal(expected, SecurityValidator.IsValidGuid(input));
}
[Fact]
public void IsValidGuid_WithNewGuid_ReturnsTrue()
{
Assert.True(SecurityValidator.IsValidGuid(Guid.NewGuid().ToString()));
}
// --- EnsureValidGuid ---
[Fact]
public void EnsureValidGuid_WithValidGuid_DoesNotThrow()
{
var ex = Record.Exception(() =>
SecurityValidator.EnsureValidGuid(Guid.NewGuid().ToString(), "TenantId"));
Assert.Null(ex);
}
[Fact]
public void EnsureValidGuid_WithNull_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
SecurityValidator.EnsureValidGuid(null, "TenantId"));
Assert.Contains("TenantId", ex.Message);
}
[Fact]
public void EnsureValidGuid_WithEmptyString_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
SecurityValidator.EnsureValidGuid("", "SessionId"));
Assert.Contains("SessionId", ex.Message);
}
[Fact]
public void EnsureValidGuid_WithInvalidString_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() =>
SecurityValidator.EnsureValidGuid("invalid-tenant", "TenantId"));
Assert.Equal("TenantId", ex.ParamName);
}
[Fact]
public void EnsureValidGuid_ErrorMessage_MentionsParameterName()
{
var ex = Assert.Throws<ArgumentException>(() =>
SecurityValidator.EnsureValidGuid("bad", "MyParam"));
Assert.Contains("MyParam", ex.Message);
}
// --- OData injection prevention (bootstrap token attack vectors) ---
[Theory]
[InlineData("' or '1'='1")]
[InlineData("' or Token ne '")]
[InlineData("' or PartitionKey ne 'CodeLookup")]
[InlineData("a1b2c3d4-e5f6-7890-abcd-ef1234567890' or '1'='1")]
[InlineData("'; --")]
[InlineData("' or 1 eq 1 or '")]
public void IsValidGuid_WithODataInjectionPayload_ReturnsFalse(string payload)
{
Assert.False(SecurityValidator.IsValidGuid(payload));
}
// --- Intune device id extraction from the MDM client certificate subject ---
// Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN
// (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the
// CloudPc validator, so the parse must be strict: CN present AND a canonical GUID.
[Fact]
public void TryGetIntuneDeviceId_PlainCnGuid_Extracts()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("O=Contoso, C=DE")] // no CN at all
[InlineData("CN=not-a-guid")] // CN present but not a GUID
[InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert
[InlineData("CN=' or '1'='1")] // injection payload never survives
public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject)
{
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id));
Assert.Null(id);
}
[Fact]
public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive()
{
// A first CN that is not a GUID means "not an Intune MDM device cert shape" — a
// later GUID-valued CN must NOT resurrect the parse (no CN-shopping).
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));
}
}