Graph/Entra response parsers on System.Text.Json (D-420)

The eight parsers for Microsoft Graph and Entra token-endpoint responses read
through a new helper, Helpers/UpstreamJson, instead of Newtonsoft.Json:
GraphTokenService, EntraTokenRejection, the Autopilot, corporate-identifier,
device-association, Cloud PC and Intune-binding validators, and the script
display-name resolver. Every call site keeps its flow; the existing
catch (JsonException) blocks now bind to System.Text.Json.

Unchanged and pinned by the characterization tests: empty and null bodies,
malformed bodies and their outcome per validator, non-object list entries
(transient), paging, duplicate keys (last wins), case-sensitive names,
comments and trailing commas, the token lifetime.

Deliberate differences, each pinned by a test named _D1 to _D7:
- D1 JSON null in a text field reads as null instead of "".
- D2 dates keep their fraction and are culture-independent UTC.
- D3 date-shaped text is kept as sent.
- D4 non-text values, and text whose escapes are no valid UTF-16, read as
  null (fail-closed).
- D5 an expires_in that is not a whole number falls back to the default
  token lifetime instead of throwing or being coerced.
- D6 an error code beyond Int64 is skipped instead of throwing.
- D7 a body cut off mid-object is unreadable; Newtonsoft's DeserializeObject
  read the part before the cut as if it were complete.

Also:
- The script resolver caches a per-ID 200 without a name, as the full pull
  already does, so a nameless script costs no Graph call on every view.
- The token cache caps expires_in before the TimeSpan conversion; a value
  near Int64.MaxValue overflowed instead of hitting the 55-minute cap.
- New UpstreamJsonTests cover every helper member and a culture matrix;
  a TTL test records the cache lifetime per expires_in variant.
- The differential test compares a cut-off body with the parser's answer to
  an unreadable body (D7) and stays green on 20,000 cases per parser.
- The Newtonsoft guard baseline loses the eight parser files and the dead
  using in MaintenanceService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-11-06 11:40:25 +01:00
parent f6f88161f9
commit 48baffe753
19 changed files with 832 additions and 254 deletions
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Functions.Services;
@@ -218,10 +219,12 @@ public class AutopilotDeviceValidatorTests
private const string Upn = "first.last@example.invalid";
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
@@ -241,7 +244,7 @@ public class AutopilotDeviceValidatorTests
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
@@ -256,7 +259,7 @@ public class AutopilotDeviceValidatorTests
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
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));
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
Assert.Null(result.AssignedToUserPrincipalName);
Assert.NotNull(result.PreAssociationDateTime);
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
}
@@ -130,6 +130,36 @@ public class ScriptDisplayNameResolverTests
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
}
[Theory]
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
[InlineData("{\"id\":\"nameless\"}")]
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
{
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
// is a cache hit instead of another Graph call.
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Platform,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Platform, "nameless");
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
Assert.Null(entry.DisplayName);
}
[Fact]
public async Task Empty_refs_returns_empty_dict()
{
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D6 pin a
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
/// deliberate difference of that move (listed in D-420).
/// </summary>
public class GraphResponseParsingTests
@@ -56,22 +56,22 @@ public class GraphResponseParsingTests
public void Token_response_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
// D5: an unusable expires_in makes the read throw today.
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
[Theory]
[InlineData("\"abc\"")]
[InlineData("\"\"")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("99999999999999999999")]
public void Token_response_with_an_unusable_lifetime_D5(string expiresIn)
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}"""));
// D5: a fractional or boolean expires_in is coerced today.
[Theory]
[InlineData("3599.5", 3600L)]
[InlineData("true", 1L)]
public void Token_response_with_a_coerced_lifetime_D5(string expiresIn, long today)
=> Assert.Equal(today, GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""").ExpiresInSeconds);
[InlineData("3599.5")]
[InlineData("true")]
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
Assert.Equal("t", token);
Assert.Null(lifetime);
}
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
@@ -106,15 +106,15 @@ public class GraphResponseParsingTests
Assert.Null(rejection.Description);
}
// D3: a description shaped like a date is dropped today.
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Entra_rejection_with_a_date_shaped_description_D3()
=> Assert.Null(EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
public void Entra_rejection_keeps_a_date_shaped_description_D3()
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
// D6: an error code beyond Int64 makes the parse throw today.
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
[Fact]
public void Entra_rejection_with_an_out_of_range_code_D6()
=> Assert.Throws<InvalidCastException>(() => EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}"""));
public void Entra_rejection_skips_an_out_of_range_code_D6()
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
@@ -172,21 +172,21 @@ public class GraphResponseParsingTests
Assert.False(registration.UserAssigned);
}
// D4: a numeric serial matches through its text today.
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Autopilot_list_with_a_numeric_serial_D4()
=> Assert.NotNull(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
// D3: registration text shaped like a date is dropped today.
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Autopilot_registration_with_date_shaped_text_D3()
public void Autopilot_registration_keeps_date_shaped_text_D3()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
Serial);
Assert.Null(registration!.GroupTag);
Assert.Null(registration.DisplayName);
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
}
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
@@ -245,10 +245,10 @@ public class GraphResponseParsingTests
}
}
// D4: an identifier that is an array matches through its text today.
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
[Fact]
public void Corporate_page_with_an_array_identifier_D4()
=> Assert.True(CorporateIdentifierValidator.ReadIdentityPage(
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
CorporateId).Found);
@@ -302,27 +302,44 @@ public class GraphResponseParsingTests
public void Association_date_that_is_unset_reads_as_null(string body)
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
// D1: JSON null in a text field reads as an empty string today.
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Association_fields_that_are_json_null_D1()
public void Association_fields_that_are_json_null_read_as_null_D1()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
Serial);
Assert.Equal(string.Empty, result.AssociationState);
Assert.Equal(string.Empty, result.AssignedToUserPrincipalName);
Assert.Equal(string.Empty, result.ManagedDeviceId);
Assert.Null(result.AssociationState);
Assert.Null(result.AssignedToUserPrincipalName);
Assert.Null(result.ManagedDeviceId);
}
// D2: the date loses its fraction today (in any culture, since format and parse share it).
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
[Fact]
public void Association_text_with_a_broken_escape_reads_as_null_D4()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
Assert.True(result.IsValid);
Assert.Null(result.AssignedToUserPrincipalName);
}
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
public void Association_date_precision_D2(string culture)
=> Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), InCulture(culture, () =>
DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
Serial).PreAssociationDateTime));
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
Serial).PreAssociationDateTime);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
}
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
@@ -359,15 +376,16 @@ public class GraphResponseParsingTests
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
}
// D1: JSON null in a text field reads as an empty string today.
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void CloudPc_fields_that_are_json_null_D1()
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
Assert.Equal(string.Empty, result.CloudPcId);
Assert.Equal(string.Empty, result.ManagedDeviceName);
Assert.Equal(string.Empty, result.ServicePlanName);
Assert.True(result.IsValid);
Assert.Null(result.CloudPcId);
Assert.Null(result.ManagedDeviceName);
Assert.Null(result.ServicePlanName);
}
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
@@ -425,10 +443,9 @@ public class GraphResponseParsingTests
[Fact]
public void Managed_device_reads_its_fields()
{
// en-US: the Newtonsoft path formats the date in the current culture before parsing it invariantly.
var result = InCulture("en-US", () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
IntuneId));
IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal("DESKTOP-1", result.DeviceName);
@@ -444,33 +461,64 @@ public class GraphResponseParsingTests
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
// D1: JSON null in a text field reads as an empty string today.
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Managed_device_fields_that_are_json_null_D1()
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
Assert.Equal(string.Empty, result.DeviceName);
Assert.Equal(string.Empty, result.AzureAdDeviceId);
Assert.Equal(string.Empty, result.ManagementState);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.DeviceName);
Assert.Null(result.AzureAdDeviceId);
Assert.Null(result.ManagementState);
}
// D2: the enrollment date loses its fraction, and on day-first cultures day and month swap, today.
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
// fraction and swapped day and month on day-first cultures).
[Theory]
[InlineData("en-US", 5, 8)]
[InlineData("de-DE", 8, 5)]
[InlineData("en-GB", 8, 5)]
public void Managed_device_enrollment_date_D2(string culture, int month, int day)
=> Assert.Equal(new DateTimeOffset(2026, month, day, 12, 16, 49, TimeSpan.Zero), InCulture(culture, () =>
IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime));
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
// D4: a numeric id matches through its text today.
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
// D4: an id that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Managed_device_with_a_numeric_id_D4()
=> Assert.Equal(IntuneDeviceBindingOutcome.Match,
public void Managed_device_with_a_numeric_id_is_not_found_D4()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
// depended on the host's time zone; it was only right on a UTC host.
[Theory]
[InlineData("2026-05-08T14:16:49+02:00")]
[InlineData("2026-05-08T07:16:49-05:00")]
[InlineData("2026-05-08T12:16:49Z")]
[InlineData("2026-05-08T12:16:49")]
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
{
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
Assert.Equal(expected, association);
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
Assert.Equal(new DateTimeOffset(expected), enrolled);
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
}
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
[Fact]
@@ -520,25 +568,79 @@ public class GraphResponseParsingTests
public void Script_name_reads_the_display_name(string body, string? name)
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
// D1: a display name that is JSON null reads as an empty string today.
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
[Fact]
public void Script_name_that_is_json_null_D1()
public void Script_name_that_is_json_null_reads_as_null_D1()
{
Assert.Equal(string.Empty, ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
Assert.Equal(string.Empty, Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
}
// D3: a display name shaped like a date is rewritten in the current culture today.
[Fact]
public void Script_name_shaped_like_a_date_D3()
=> Assert.Equal("1/1/2026 12:00:00 AM", InCulture("en-US", () =>
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("th-TH")]
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
// D4: a display name that is a number reads as its text today.
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
[Fact]
public void Script_name_that_is_a_number_D4()
=> Assert.Equal("42", ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
public void Script_name_that_is_a_number_reads_as_null_D4()
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
[Fact]
public void Token_response_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
[Fact]
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
{
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
[Fact]
public void Autopilot_list_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
[Fact]
public void Corporate_page_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
CorporateId));
[Fact]
public void Association_list_cut_off_is_not_found_D7()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_cut_off_is_not_found_D7()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Managed_device_cut_off_is_not_found_D7()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
private static T InCulture<T>(string culture, Func<T> read)
{
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
})
.Build();
var svc = NewService(config, new StubHttpClientFactory(handler));
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
return (svc, handler);
}
@@ -47,11 +47,12 @@ public class GraphTokenServiceTests
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
IMemoryCache? cache = null) =>
new GraphTokenService(
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
cache ?? new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
@@ -131,6 +132,53 @@ public class GraphTokenServiceTests
Assert.Equal(1, TokenPosts(handler, TenantA));
}
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
[Theory]
[InlineData("3599", 3299)]
[InlineData("\"3599\"", 3299)]
[InlineData("7200", 3300)]
[InlineData("120", 60)]
[InlineData("null", 1800)]
[InlineData("\"abc\"", 1800)]
[InlineData("3599.5", 1800)]
[InlineData("0", 1800)]
[InlineData("-5", 1800)]
[InlineData("9223372036854775807", 3300)]
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
{
var cache = new RecordingMemoryCache();
var (svc, _) = Create(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
}
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
private sealed class RecordingMemoryCache : IMemoryCache
{
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
public List<ICacheEntry> Entries { get; } = new();
public ICacheEntry CreateEntry(object key)
{
var entry = _inner.CreateEntry(key);
Entries.Add(entry);
return entry;
}
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
public void Remove(object key) => _inner.Remove(key);
public void Dispose() => _inner.Dispose();
}
[Fact]
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
{
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
Assert.Null(result.EnrolledDateTime);
}
// -- ParseEnrolledDateTime --
// -- enrolledDateTime --
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
[Fact]
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("20260824")]
[InlineData("true")]
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
{
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
Assert.Null(EnrolledDateTime(jsonValue));
}
[Fact]
public void ParseEnrolledDateTime_NormalizesToUtc()
public void EnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
@@ -26,8 +26,6 @@ public class NewtonsoftUsageGuardTests
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
private const string OutgoingPayload = "outgoing notification payloads";
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
private const string UpstreamGraph = "Graph and Entra token responses";
private const string DeadUsing = "unused using directive";
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
@@ -135,17 +133,6 @@ public class NewtonsoftUsageGuardTests
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
["Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/EntraTokenRejection.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Services/GraphResolution/ScriptDisplayNameResolver.cs"] = UpstreamGraph,
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs"] = DeadUsing,
};
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
@@ -18,7 +18,9 @@ namespace AutopilotMonitor.Functions.Tests;
/// lists of mixed entries, text with escapes and Unicode, paging links, lifetimes as number or numeric text,
/// error-code variants. They leave out the inputs of the deliberate differences D1 to D6 (JSON null or non-text
/// in a text field, date-shaped text, dates with fractions or offsets, unusable lifetimes, codes beyond Int64);
/// those are pinned in <see cref="GraphResponseParsingTests"/>.
/// those are pinned in <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with
/// the production parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part
/// before the cut as if the open containers were closed.
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog).
/// </summary>
public class UpstreamJsonDifferentialTests
@@ -104,13 +106,14 @@ public class UpstreamJsonDifferentialTests
try
{
var generator = new Cases(seed);
var unreadable = Outcome(() => actual("not json"));
var mismatches = new List<string>();
int hits = 0, throws = 0;
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
{
var input = body(generator);
var got = Outcome(() => actual(input));
var want = Outcome(() => reference(input));
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
if (got != want)
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
if (hit(want)) hits++;
@@ -232,19 +235,27 @@ public class UpstreamJsonDifferentialTests
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
}
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
public bool LastWasCutOff { get; private set; }
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
private string Root(string objectJson) => _rng.Next(100) switch
private string Root(string objectJson)
{
< 82 => Ws() + objectJson + Ws(),
< 84 => "",
< 86 => " ",
< 88 => "null",
< 90 => "[" + objectJson + "]",
< 92 => Pick("42", "\"text\"", "true"),
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
< 98 => objectJson + Pick(" x", " {}", " 1"),
_ => Pick("<html>busy</html>", "not json"),
};
var pick = _rng.Next(100);
LastWasCutOff = pick is >= 92 and < 95;
return pick switch
{
< 82 => Ws() + objectJson + Ws(),
< 84 => "",
< 86 => " ",
< 88 => "null",
< 90 => "[" + objectJson + "]",
< 92 => Pick("42", "\"text\"", "true"),
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
< 98 => objectJson + Pick(" x", " {}", " 1"),
_ => Pick("<html>busy</html>", "not json"),
};
}
private string Matching(string target) => Pick(
Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true");
@@ -0,0 +1,298 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
/// <see cref="GraphResponseParsingTests"/>.
/// </summary>
public class UpstreamJsonTests
{
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\r\n\t")]
[InlineData("null")]
[InlineData(" null ")]
[InlineData("/*c*/ null")]
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
[Theory]
[InlineData("{}")]
[InlineData(" { \"a\" : 1 } ")]
[InlineData("{\"a\":1,}")]
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
[InlineData("// leading\n{\"a\":1}")]
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
[Fact]
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
{
// The document is disposed inside the call; only a cloned root stays readable.
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
GC.Collect();
Assert.Equal("b", json.GetProperty("a").GetString());
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
}
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
// is malformed too; Graph and Entra never send it.
[Theory]
[InlineData("[]")]
[InlineData("[{}]")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("true")]
[InlineData("not json")]
[InlineData("<html>busy</html>")]
[InlineData("{")]
[InlineData("{\"a\":}")]
[InlineData("{} x")]
[InlineData("{}{}")]
[InlineData("{} /* unterminated")]
[InlineData("{'a':'b'}")]
[InlineData("{a:1}")]
[InlineData("{\"a\":NaN}")]
[InlineData("{\"a\":0x1F}")]
[InlineData("{\"a\":undefined}")]
[InlineData("{\"a\":\"line\nbreak\"}")]
[InlineData("{\"a\":1,,}")]
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
[Fact]
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
// ── GetString ───────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{"a":"value"}""", "value")]
[InlineData("""{"a":""}""", "")]
[InlineData("""{"a":" padded "}""", " padded ")]
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
[InlineData("""{"a":"42"}""", "42")]
[InlineData("""{"a":"first","a":"last"}""", "last")]
public void GetString_reads_text_as_sent(string json, string expected)
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"a":null}""")]
[InlineData("""{"a":42}""")]
[InlineData("""{"a":4.2}""")]
[InlineData("""{"a":true}""")]
[InlineData("""{"a":false}""")]
[InlineData("""{"a":{}}""")]
[InlineData("""{"a":[]}""")]
[InlineData("""{"a":["x"]}""")]
[InlineData("""{"A":"other case"}""")]
[InlineData("""{"a ":"other name"}""")]
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("[]")]
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
{
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
}
[Theory]
[InlineData("""{"a":"x\ud800y"}""")]
[InlineData("""{"a":"\udc00"}""")]
[InlineData("""{"a":"\ud800A"}""")]
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
// ── Items ───────────────────────────────────────────────────────────────────
[Fact]
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
=> Assert.Equal(
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":42}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"Value":[1]}""")]
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
// ── GetNextLink ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{}""", null)]
[InlineData("""{"@odata.nextLink":null}""", null)]
[InlineData("""{"@odata.nextLink":""}""", "")]
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
// ── GetInt64 ────────────────────────────────────────────────────────────────
[Theory]
[InlineData("3599", 3599L)]
[InlineData("0", 0L)]
[InlineData("-5", -5L)]
[InlineData("9223372036854775807", long.MaxValue)]
[InlineData("-9223372036854775808", long.MinValue)]
[InlineData("\"3599\"", 3599L)]
[InlineData("\" 3599 \"", 3599L)]
[InlineData("\"+3599\"", 3599L)]
[InlineData("\"-1\"", -1L)]
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Theory]
[InlineData("3599.5")]
[InlineData("3599.0")]
[InlineData("7e2")]
[InlineData("9223372036854775808")]
[InlineData("99999999999999999999")]
[InlineData("\"3599.0\"")]
[InlineData("\"3,599\"")]
[InlineData("\"3.599\"")]
[InlineData("\"0x10\"")]
[InlineData("\"\"")]
[InlineData("\"abc\"")]
[InlineData("\"\u0661\u0662\u0663\"")]
[InlineData("\"\\ud800\"")]
[InlineData("null")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Fact]
public void GetInt64_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
// ── GetBoolean ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("\"false\"", null)]
[InlineData("1", null)]
[InlineData("0", null)]
[InlineData("{}", null)]
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
[Fact]
public void GetBoolean_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
[Theory]
[InlineData("2026-05-08T12:16:49Z", 0L)]
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
[InlineData("2026-05-08T12:16:49", 0L)]
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
{
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
[Fact]
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
[Theory]
[InlineData("\"0001-01-01T00:00:00Z\"")]
[InlineData("\"0001-01-01T00:00:00\"")]
[InlineData("\"0001-01-01\"")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("\"2026-13-01T00:00:00Z\"")]
[InlineData("\"/Date(1713096021000)/\"")]
[InlineData("null")]
[InlineData("20260508")]
[InlineData("true")]
[InlineData("{}")]
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
[Fact]
public void GetUtcDateTime_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
// ── Culture matrix ──────────────────────────────────────────────────────────
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
// read the same body the same way.
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Every_member_reads_the_same_in_any_culture(string culture)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
Assert.Null(UpstreamJson.GetInt64(json, "f"));
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Reads Microsoft Graph and Entra token-endpoint responses with System.Text.Json (D-420). The members keep
/// the outcomes the former Newtonsoft code had for every realistic body; GraphResponseParsingTests holds the
/// contract, including the deliberate differences.
/// </summary>
internal static class UpstreamJson
{
// Newtonsoft skipped comments and accepted trailing commas; keep both.
private static readonly JsonDocumentOptions ReadOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// The body's root object, or null for an empty or blank body and for a JSON null root. Malformed JSON,
/// trailing content and any other root kind throw <see cref="JsonException"/>, which is what the callers'
/// existing catch blocks expect.
/// </summary>
internal static JsonElement? ParseObjectOrNull(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
using var document = JsonDocument.Parse(body, ReadOptions);
var root = document.RootElement;
return root.ValueKind switch
{
JsonValueKind.Object => root.Clone(),
JsonValueKind.Null => null,
_ => throw new JsonException($"Expected a JSON object, got {root.ValueKind}."),
};
}
/// <summary>
/// The property's text; null when it is absent, JSON null, not a string, or text whose escapes are no valid
/// UTF-16. <paramref name="obj"/> must be an object: a list entry of another kind throws
/// <see cref="InvalidOperationException"/>, which the validators turn into a transient result, as before.
/// </summary>
internal static string? GetString(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) ? TextOf(value) : null;
// A lone surrogate escape such as \ud800 makes GetString throw; it reads as absent like any other unusable
// value, so one corrupt field cannot turn a whole lookup transient.
private static string? TextOf(JsonElement value)
{
if (value.ValueKind != JsonValueKind.String)
return null;
try
{
return value.GetString();
}
catch (InvalidOperationException)
{
return null;
}
}
/// <summary>The entries of an array property, of any kind; empty when it is absent or not an array.</summary>
internal static IEnumerable<JsonElement> Items(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var array) && array.ValueKind == JsonValueKind.Array
? array.EnumerateArray()
: Enumerable.Empty<JsonElement>();
/// <summary>
/// The page's <c>@odata.nextLink</c>; null when absent or JSON null. Any other value, text that is no valid
/// UTF-16 included, throws <see cref="JsonException"/>, so a scan never ends as complete on a link it cannot
/// follow.
/// </summary>
internal static string? GetNextLink(JsonElement page)
{
if (!page.TryGetProperty("@odata.nextLink", out var link) || link.ValueKind == JsonValueKind.Null)
return null;
return TextOf(link) ?? throw new JsonException($"@odata.nextLink is no readable text ({link.ValueKind}).");
}
/// <summary>A whole number, or a string holding one (Entra v1 and ADFS send expires_in as text); else null.</summary>
internal static long? GetInt64(JsonElement obj, string name)
{
if (!obj.TryGetProperty(name, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt64(out var number) => number,
JsonValueKind.String when long.TryParse(TextOf(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => null,
};
}
/// <summary>true or false; any other kind, "true" in quotes or 1 included, is null.</summary>
internal static bool? GetBoolean(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
? value.GetBoolean()
: null;
/// <summary>
/// An ISO date string as a UTC instant, read with the invariant culture and full precision; a value
/// without an offset counts as UTC. Null when absent, not a string, blank, unreadable, or Graph's
/// <c>0001-01-01</c> placeholder for an unset date.
/// </summary>
internal static DateTimeOffset? GetUtcDateTime(JsonElement obj, string name)
{
var raw = GetString(obj, name);
if (string.IsNullOrWhiteSpace(raw)
|| !DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
}
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -261,16 +261,15 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
internal static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is not JArray devices || devices.Count == 0)
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return null;
var exactDevice = devices
var exactDevice = UpstreamJson.Items(data, "value")
.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
return exactDevice == null ? null : ReadRegistration(exactDevice);
return exactDevice.ValueKind == JsonValueKind.Undefined ? null : ReadRegistration(exactDevice);
}
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
@@ -281,18 +280,18 @@ namespace AutopilotMonitor.Functions.Security
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
/// never read.
/// </summary>
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
internal static AutopilotRegistration ReadRegistration(JsonElement device) => new AutopilotRegistration
{
DeviceId = RegistrationText(device["id"]),
GroupTag = RegistrationText(device["groupTag"]),
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(device["displayName"]),
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
DeviceId = RegistrationText(device, "id"),
GroupTag = RegistrationText(device, "groupTag"),
PurchaseOrderId = RegistrationText(device, "purchaseOrderIdentifier"),
DisplayName = RegistrationText(device, "displayName"),
UserAssigned = RegistrationText(device, "userPrincipalName") != null,
};
private static string? RegistrationText(JToken? token)
private static string? RegistrationText(JsonElement device, string name)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
var value = UpstreamJson.GetString(device, name)?.Trim();
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
@@ -6,8 +6,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -245,35 +245,34 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
JsonElement? data;
try
{
data = JsonConvert.DeserializeObject<JObject>(responseBody);
data = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
if (data is not { } json)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(),
var match = UpstreamJson.Items(json, "value").FirstOrDefault(c => string.Equals(
UpstreamJson.GetString(c, "managedDeviceId")?.Trim(),
normalizedId,
StringComparison.OrdinalIgnoreCase));
if (match == null)
if (match.ValueKind == JsonValueKind.Undefined)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
CloudPcId = UpstreamJson.GetString(match, "id"),
ManagedDeviceName = UpstreamJson.GetString(match, "managedDeviceName"),
ServicePlanName = UpstreamJson.GetString(match, "servicePlanName")
};
}
@@ -7,8 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -329,23 +328,22 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
internal static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is JArray identities)
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return (false, string.Empty);
foreach (var identity in UpstreamJson.Items(data, "value"))
{
foreach (var identity in identities)
var type = UpstreamJson.GetString(identity, "importedDeviceIdentityType");
var value = UpstreamJson.GetString(identity, "importedDeviceIdentifier");
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
return (true, string.Empty);
}
}
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
return (false, UpstreamJson.GetNextLink(data) ?? string.Empty);
}
/// <summary>
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -236,56 +236,42 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
JsonElement? data;
try
{
data = JsonConvert.DeserializeObject<JObject>(responseBody);
data = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
if (data is not { } json)
return notFound;
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
var match = UpstreamJson.Items(json, "value").FirstOrDefault(d => string.Equals(
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
if (match == null)
if (match.ValueKind == JsonValueKind.Undefined)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseDateTime(match["preassociationDateTime"]),
AssociationDateTime = ParseDateTime(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
AssociationState = UpstreamJson.GetString(match, "associationState"),
DevicePreparationPolicyId = UpstreamJson.GetString(match, "devicePreparationPolicyId"),
PreAssociatedByUserPrincipalName = UpstreamJson.GetString(match, "preassociatedByUserPrincipalName"),
AssignedToUserPrincipalName = UpstreamJson.GetString(match, "assignedToUserPrincipalName"),
// Graph returns "0001-01-01T00:00:00Z" for unset dates; GetUtcDateTime reads it as null.
PreAssociationDateTime = UpstreamJson.GetUtcDateTime(match, "preassociationDateTime")?.UtcDateTime,
AssociationDateTime = UpstreamJson.GetUtcDateTime(match, "associationDateTime")?.UtcDateTime,
ManagedDeviceId = UpstreamJson.GetString(match, "managedDeviceId")
};
}
private static DateTime? ParseDateTime(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
// Graph returns "0001-01-01T00:00:00Z" for unset DateTimeOffset fields — treat as null.
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
{
if (dt.Year <= 1) return null;
return dt;
}
return null;
}
internal static string BuildCacheKey(string tenantId, string serialNumber)
=> $"device-association:{tenantId}:{serialNumber}";
@@ -1,7 +1,7 @@
using System;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -39,16 +39,15 @@ namespace AutopilotMonitor.Functions.Security
public static EntraTokenRejection Parse(int statusCode, string? body)
{
var json = TryParseObject(body);
var description = (json?["error_description"] as JValue)?.Value as string;
var description = json is { } root ? UpstreamJson.GetString(root, "error_description") : null;
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
}
private static JObject? TryParseObject(string? body)
private static JsonElement? TryParseObject(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
return UpstreamJson.ParseObjectOrNull(body);
}
catch (JsonException)
{
@@ -56,10 +55,17 @@ namespace AutopilotMonitor.Functions.Security
}
}
private static string? ReadCode(JObject? json, string? description)
private static string? ReadCode(JsonElement? json, string? description)
{
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null) return CodePrefix + first.Value<long>();
if (json is { } root)
{
// The first whole number that fits Int64; text, fractions and larger numbers are skipped.
foreach (var code in UpstreamJson.Items(root, "error_codes"))
{
if (code.ValueKind == JsonValueKind.Number && code.TryGetInt64(out var value))
return CodePrefix + value;
}
}
// Fallback for a body without error_codes: the description starts with the code.
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
@@ -9,8 +9,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -469,21 +468,24 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Reads a successful token response: the access token, and expires_in only when a token came
/// back. A body that is not a JSON object throws; the caller's retry loop handles it.
/// back. A body that is not a JSON object throws <see cref="System.Text.Json.JsonException"/>, which the
/// retry loop does not catch: it leaves the token service like any other unexpected failure.
/// </summary>
internal static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } tokenJson)
return (null, null);
var accessToken = UpstreamJson.GetString(tokenJson, "access_token");
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, UpstreamJson.GetInt64(tokenJson, "expires_in"));
}
private void CacheToken(string tenantId, string clientId, string accessToken, long genAtStart, long? expiresInSeconds)
{
// expires_in is reported by AAD over TLS, so it is trustworthy — but still clamped so a
// missing or pathological value cannot pin a stale token (cap) or cause thrash (floor).
// The seconds are capped before the conversion: a TimeSpan cannot hold the far end of a long.
var lifetime = expiresInSeconds is > 0
? TimeSpan.FromSeconds(expiresInSeconds.Value)
? TimeSpan.FromSeconds(Math.Min(expiresInSeconds.Value, (long)(MaxTokenTtl + TokenSkewMargin).TotalSeconds))
: DefaultTokenLifetime;
var ttl = lifetime - TokenSkewMargin;
if (ttl < MinTokenTtl) ttl = MinTokenTtl;
@@ -8,8 +8,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -401,40 +401,42 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
JsonElement? parsed;
try
{
device = JsonConvert.DeserializeObject<JObject>(responseBody);
parsed = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var returnedId = device?["id"]?.ToString()?.Trim();
if (parsed is not { } device)
return notFound;
var returnedId = UpstreamJson.GetString(device, "id")?.Trim();
if (string.IsNullOrEmpty(returnedId) ||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
{
return notFound;
}
var managementState = device?["managementState"]?.ToString();
var managementState = UpstreamJson.GetString(device, "managementState");
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
DeviceName = UpstreamJson.GetString(device, "deviceName"),
AzureAdDeviceId = UpstreamJson.GetString(device, "azureADDeviceId"),
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
SerialNumber = NullIfEmpty(UpstreamJson.GetString(device, "serialNumber")),
OwnerType = NullIfEmpty(UpstreamJson.GetString(device, "managedDeviceOwnerType")),
EnrollmentType = NullIfEmpty(UpstreamJson.GetString(device, "deviceEnrollmentType")),
AzureAdRegistered = UpstreamJson.GetBoolean(device, "azureADRegistered"),
// Intune returns "0001-01-01T00:00:00Z" when it has no enrollment timestamp: absence, not a date.
EnrolledDateTime = UpstreamJson.GetUtcDateTime(device, "enrolledDateTime"),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
@@ -448,28 +450,6 @@ namespace AutopilotMonitor.Functions.Security
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
/// <summary>
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
/// <c>null</c> rather than a bogus age of two thousand years.
/// </summary>
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(
raw,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal,
out var parsed))
{
return null;
}
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
@@ -12,7 +12,8 @@ using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Services.GraphResolution;
@@ -204,8 +205,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
{
await WriteCacheEntryAsync(tenantId, r, displayName: null, fileName: null, notFound: true, budget).ConfigureAwait(false);
}
else if (displayName != null)
else
{
// A 200 answers for good even without a name: cached like the full pull caches it, a nameless
// script costs no Graph call on every view until the next full pull.
result[r] = displayName;
await WriteCacheEntryAsync(tenantId, r, displayName, fileName: null, notFound: false, budget).ConfigureAwait(false);
}
@@ -356,20 +359,25 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
/// </summary>
internal static (List<ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
var json = ParseScriptObject(body);
var scripts = new List<ScriptListItem>();
foreach (var item in values.OfType<JObject>())
foreach (var item in UpstreamJson.Items(json, "value").Where(i => i.ValueKind == JsonValueKind.Object))
{
var id = item["id"]?.ToString();
var id = UpstreamJson.GetString(item, "id");
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
scripts.Add(new ScriptListItem(id!, UpstreamJson.GetString(item, "displayName"),
withFileName ? UpstreamJson.GetString(item, "fileName") : null));
}
return (scripts, json["@odata.nextLink"]?.ToString());
return (scripts, UpstreamJson.GetNextLink(json));
}
/// <summary>Reads the display name of a single-script GET; a body that is not a JSON object throws.</summary>
internal static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
internal static string? ReadScriptDisplayName(string body) => UpstreamJson.GetString(ParseScriptObject(body), "displayName");
// An empty or null body must throw like any other unreadable one: the full pull then falls back to
// per-ID lookups instead of recording the list as fetched.
private static JsonElement ParseScriptObject(string body)
=> UpstreamJson.ParseObjectOrNull(body) ?? throw new JsonException("The Graph script response is empty or null.");
internal readonly record struct ScriptListItem(string Id, string? DisplayName, string? FileName);
@@ -13,7 +13,6 @@ using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{