mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-09 03:22:39 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+30
@@ -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)
|
||||
|
||||
+17
-9
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user