Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs
T
Oliver Kieselbach 3dbfdc0dba fix: make DevPrep shadow lookup fire-and-forget
The shadow validator previously awaited the Graph round-trip inline,
adding 200ms-30s of latency to every RegisterSession/Ingest response
on opted-in tenants — defeating the "shadow, never blocks" intent.

Detach the lookup as Task.Run with captured locals so the agent gets
its OK immediately. Result is logged at Warning level (Information
gets filtered by sampling/log-level config and never surfaced).

Drop the now-unused EnrichRequestTelemetryWithDeviceAssociation method
and its tests — Activity.AddTag from a detached task wouldn't make it
into the request telemetry anyway, since the parent activity has
already stopped by the time the background lookup completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:33:14 +02:00

131 lines
5.5 KiB
C#

using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="DeviceAssociationValidator"/> — focused on pure-function pieces:
/// JSON-to-DTO mapping (incl. exact-match guard) and cache key shape. The HTTP/cache/retry
/// resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>; behavioural drift
/// would surface here.
/// </summary>
public class DeviceAssociationValidatorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Serial = "1730-2406-2605-6305-0260-8436-93";
// -- BuildCacheKey --
[Fact]
public void BuildCacheKey_StableShape()
{
var key = DeviceAssociationValidator.BuildCacheKey(TenantId, Serial);
Assert.Equal($"device-association:{TenantId}:{Serial}", key);
}
[Fact]
public void BuildCacheKey_DistinctFromAutopilotValidatorKey()
{
// The two validators must NOT share cache keys; otherwise a positive Autopilot
// hit would incorrectly satisfy a DevPrep lookup.
var devPrepKey = DeviceAssociationValidator.BuildCacheKey(TenantId, Serial);
Assert.StartsWith("device-association:", devPrepKey);
Assert.DoesNotContain("autopilot", devPrepKey);
}
// -- ParseTenantAssociatedDevicesResponse: empty / missing / malformed --
[Fact]
public void Parse_EmptyValueArray_NotFound()
{
var body = "{\"value\":[]}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(Serial, result.SerialNumber);
Assert.Contains("not associated", result.ErrorMessage);
}
[Fact]
public void Parse_MissingValueProperty_NotFound()
{
var body = "{}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_MalformedJson_NotFound_NotTransient()
{
// Defensive: bad payload is treated as "not associated", not as a transient
// — transient handling is only triggered by HTTP-level failures.
var body = "this is not json";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
// -- ParseTenantAssociatedDevicesResponse: exact-match guard --
[Fact]
public void Parse_OnlySimilarSerial_NotFound()
{
// Guards against widened filter semantics: even if Graph returns devices whose serial
// merely contains the query, we must require an exact match before declaring success.
var body = $@"{{""value"":[{{""serialNumber"":""{Serial}-OTHER"",""associationState"":""preassociated""}}]}}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_ExactSerialMatch_PopulatesAllFields()
{
var body = $@"{{
""value"":[{{
""id"":""f8226ccb-e464-1842-a341-325b2a4fd906"",
""serialNumber"":""{Serial}"",
""associationState"":""preassociated"",
""devicePreparationPolicyId"":""00000000-0000-0000-0000-000000000000"",
""preassociationDateTime"":""2026-04-14T12:00:21.5029229Z"",
""associationDateTime"":""0001-01-01T00:00:00Z"",
""preassociatedByUserPrincipalName"":""admin@contoso.com"",
""assignedToUserPrincipalName"":null,
""managedDeviceId"":""00000000-0000-0000-0000-000000000000""
}}]
}}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.True(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(Serial, result.SerialNumber);
Assert.Equal("preassociated", result.AssociationState);
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
Assert.NotNull(result.PreAssociationDateTime);
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
}
[Fact]
public void Parse_TrimsWhitespace_OnGraphSerialBeforeMatching()
{
// Defensive: protect against Graph stragglers with stray whitespace.
var body = $@"{{""value"":[{{""serialNumber"":"" {Serial} "",""associationState"":""preassociated""}}]}}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.True(result.IsValid);
}
[Fact]
public void Parse_CaseInsensitiveSerialMatch()
{
var lower = Serial.ToLowerInvariant();
var upper = Serial.ToUpperInvariant();
var body = $@"{{""value"":[{{""serialNumber"":""{upper}"",""associationState"":""preassociated""}}]}}";
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, lower);
Assert.True(result.IsValid);
}
}