From 48baffe7531540c5d52cc670ae47c9deb8eecb5f Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 6 Nov 2026 11:40:25 +0100 Subject: [PATCH] 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 --- .../AutopilotDeviceValidatorTests.cs | 9 +- .../DeviceAssociationValidatorTests.cs | 5 +- .../ScriptDisplayNameResolverTests.cs | 30 ++ .../GraphResponseParsingTests.cs | 248 ++++++++++----- .../GraphTokenServiceTests.cs | 56 +++- .../IntuneDeviceBindingValidatorTests.cs | 28 +- .../NewtonsoftUsageGuardTests.cs | 13 - .../UpstreamJsonDifferentialTests.cs | 37 ++- .../UpstreamJsonTests.cs | 298 ++++++++++++++++++ .../Helpers/UpstreamJson.cs | 117 +++++++ .../Security/AutopilotDeviceValidator.cs | 29 +- .../Security/CloudPcDeviceValidator.cs | 23 +- .../Security/CorporateIdentifierValidator.cs | 26 +- .../Security/DeviceAssociationValidator.cs | 46 +-- .../Security/EntraTokenRejection.cs | 24 +- .../Security/GraphTokenService.cs | 16 +- .../Security/IntuneDeviceBindingValidator.cs | 54 +--- .../ScriptDisplayNameResolver.cs | 26 +- .../Services/MaintenanceService.cs | 1 - 19 files changed, 832 insertions(+), 254 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Helpers/UpstreamJson.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AutopilotDeviceValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AutopilotDeviceValidatorTests.cs index 4dd6ec7ce..bc9aec781 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AutopilotDeviceValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AutopilotDeviceValidatorTests.cs @@ -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(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); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs index 2c7126344..ffbb17312 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs @@ -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) } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GraphResolution/ScriptDisplayNameResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GraphResolution/ScriptDisplayNameResolverTests.cs index 6cc4cfe49..796516309 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GraphResolution/ScriptDisplayNameResolverTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GraphResolution/ScriptDisplayNameResolverTests.cs @@ -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() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GraphResponseParsingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GraphResponseParsingTests.cs index 876b92b95..a300aba46 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GraphResponseParsingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GraphResponseParsingTests.cs @@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests; /// /// 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). /// public class GraphResponseParsingTests @@ -56,22 +56,22 @@ public class GraphResponseParsingTests public void Token_response_that_is_not_one_object_throws(string body) => Assert.ThrowsAny(() => 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(() => 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(() => 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(() => 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(() => AutopilotDeviceValidator.FindRegistration( + """{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial)); + + [Fact] + public void Corporate_page_cut_off_throws_D7() + => Assert.ThrowsAny(() => 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(string culture, Func read) { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GraphTokenServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GraphTokenServiceTests.cs index 114e69ffb..1e122ca41 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GraphTokenServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GraphTokenServiceTests.cs @@ -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 @@ -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? logger = null) => + Microsoft.Extensions.Logging.ILogger? logger = null, + IMemoryCache? cache = null) => new GraphTokenService( logger ?? NullLogger.Instance, factory, - new MemoryCache(new MemoryCacheOptions()), + cache ?? new MemoryCache(new MemoryCacheOptions()), new EntraAppRegistry(config, NullLogger.Instance), new AutopilotMonitor.Functions.Services.TenantConfigurationService( Moq.Mock.Of(), @@ -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); + } + + /// A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose. + private sealed class RecordingMemoryCache : IMemoryCache + { + private readonly MemoryCache _inner = new(new MemoryCacheOptions()); + + public List 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() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs index 43472e3ee..16de4e2ca 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NewtonsoftUsageGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NewtonsoftUsageGuardTests.cs index dd1064972..8d41b449c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/NewtonsoftUsageGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/NewtonsoftUsageGuardTests.cs @@ -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"; /// Path relative to src/ → why it may still use Newtonsoft. Only shrinks. private static readonly Dictionary 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, }; /// The projects that may reference the Newtonsoft.Json package directly. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonDifferentialTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonDifferentialTests.cs index 6a855a3c1..975c4a99f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonDifferentialTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonDifferentialTests.cs @@ -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 . +/// those are pinned in . 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 after a clean production run (backlog). /// 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(); 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() + "]"; } + /// Whether the last body was the object cut off halfway (D7). + public bool LastWasCutOff { get; private set; } + /// The body around an object: mostly the object itself, else a root or shape both refuse. - 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("busy", "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("busy", "not json"), + }; + } private string Matching(string target) => Pick( Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true"); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonTests.cs new file mode 100644 index 000000000..336ee9774 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UpstreamJsonTests.cs @@ -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; + +/// +/// The contract of (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 +/// . +/// +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("busy")] + [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(() => UpstreamJson.ParseObjectOrNull(body)); + + [Fact] + public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit() + => Assert.ThrowsAny(() => 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(() => 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(() => 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/UpstreamJson.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/UpstreamJson.cs new file mode 100644 index 000000000..b36913b3c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/UpstreamJson.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// 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. +/// +internal static class UpstreamJson +{ + // Newtonsoft skipped comments and accepted trailing commas; keep both. + private static readonly JsonDocumentOptions ReadOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// 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 , which is what the callers' + /// existing catch blocks expect. + /// + 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}."), + }; + } + + /// + /// The property's text; null when it is absent, JSON null, not a string, or text whose escapes are no valid + /// UTF-16. must be an object: a list entry of another kind throws + /// , which the validators turn into a transient result, as before. + /// + 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; + } + } + + /// The entries of an array property, of any kind; empty when it is absent or not an array. + internal static IEnumerable Items(JsonElement obj, string name) + => obj.TryGetProperty(name, out var array) && array.ValueKind == JsonValueKind.Array + ? array.EnumerateArray() + : Enumerable.Empty(); + + /// + /// The page's @odata.nextLink; null when absent or JSON null. Any other value, text that is no valid + /// UTF-16 included, throws , so a scan never ends as complete on a link it cannot + /// follow. + /// + 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})."); + } + + /// A whole number, or a string holding one (Entra v1 and ADFS send expires_in as text); else null. + 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, + }; + } + + /// true or false; any other kind, "true" in quotes or 1 included, is null. + 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; + + /// + /// 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 + /// 0001-01-01 placeholder for an unset date. + /// + 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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs index 7c12f3f47..55edb20d2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs @@ -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 /// internal static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial) { - var data = JsonConvert.DeserializeObject(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); } /// Longest registration text a session keeps; anything beyond is cut. @@ -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. /// - 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; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs index 915bb8fe3..4c83fb606 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs @@ -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(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") }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs index 51362bbdd..b2d9431dd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs @@ -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 /// internal static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier) { - var data = JsonConvert.DeserializeObject(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); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs index a93aa7ffa..b342a3746 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs @@ -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(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}"; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EntraTokenRejection.cs b/src/Backend/AutopilotMonitor.Functions/Security/EntraTokenRejection.cs index 895026df6..571abc04b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EntraTokenRejection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EntraTokenRejection.cs @@ -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(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(); + 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; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs b/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs index 231777e74..26d7775a7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs @@ -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 /// /// 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 , which the + /// retry loop does not catch: it leaves the token service like any other unexpected failure. /// internal static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody) { - var tokenJson = JsonConvert.DeserializeObject(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; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs index 22876986b..506760cae 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs @@ -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(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() - : 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; - /// - /// Parses Graph's enrolledDateTime. Intune returns 0001-01-01T00:00:00Z for - /// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to - /// null rather than a bogus age of two thousand years. - /// - 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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GraphResolution/ScriptDisplayNameResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/GraphResolution/ScriptDisplayNameResolver.cs index ac617620f..5ed218d7b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GraphResolution/ScriptDisplayNameResolver.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GraphResolution/ScriptDisplayNameResolver.cs @@ -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 /// internal static (List 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(); - foreach (var item in values.OfType()) + 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)); } /// Reads the display name of a single-script GET; a body that is not a JSON object throws. - 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 857b995f4..d9a827d1b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -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 {