From 9e2963b2774e5ba61fe068fd80e0df19114a4b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Virgil=20Bulens=EF=BD=9C=E6=96=87=E5=89=9B?= Date: Wed, 12 Aug 2026 23:33:10 +0200 Subject: [PATCH] fix: unwrap PSObject-wrapped values when serializing Invoke-MgGraphRequest dictionary bodies (#3658) * fix: unwrap PSObject-wrapped values when serializing Invoke-MgGraphRequest dictionary bodies Invoke-MgGraphRequest -Body serializes IDictionary bodies with Newtonsoft.Json. Values that came from the PowerShell pipeline (e.g. bare $_ in ForEach-Object) are PSObject wrappers, so Newtonsoft reflected over PowerShell adapted members (such as the Chars indexed property on strings) and failed with a self-referencing loop error before the request was sent. Add a PSObjectJsonConverter that serializes the underlying BaseObject of PSObject-wrapped values at any depth, and projects pure PSCustomObjects into JSON objects, and pass it at the single JsonConvert.SerializeObject call site for dictionary bodies. Also pin the test project's Microsoft.PowerShell.SDK reference to the latest 7.4.x release: 7.5.x targets net9.0 and contributes no assemblies to the net8.0 test build, which silently left the PowerShellStandard.Library stub (whose APIs return null) as the runtime System.Management.Automation, making PSObject-dependent tests impossible. Fixes #3654 * Add newline at end of PSObjectJsonConverter.cs add space to kick off CI --------- Co-authored-by: Virgil Co-authored-by: Ramses Sanchez-Hernandez <63934382+ramsessanchez@users.noreply.github.com> --- .../Helpers/PSObjectJsonConverterTests.cs | 148 ++++++++++++++++++ ...Microsoft.Graph.Authentication.Test.csproj | 6 +- .../Cmdlets/InvokeMgGraphRequest.cs | 8 +- .../Helpers/PSObjectJsonConverter.cs | 56 +++++++ 4 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs create mode 100644 src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs diff --git a/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs b/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs new file mode 100644 index 00000000000..3baba24318e --- /dev/null +++ b/src/Authentication/Authentication.Test/Helpers/PSObjectJsonConverterTests.cs @@ -0,0 +1,148 @@ +using System.Collections; +using System.Management.Automation; +using System.Net.Http; + +using Microsoft.Graph.PowerShell.Authentication.Cmdlets; +using Microsoft.Graph.PowerShell.Authentication.Helpers; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using Xunit; + +namespace Microsoft.Graph.Authentication.Test.Helpers +{ + public class PSObjectJsonConverterTests + { + [Fact] + public void ShouldSerializePSObjectWrappedStringAsPlainString() + { + // Mimics a value produced by the PowerShell pipeline, e.g. bare $_ in ForEach-Object. + // See https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3654. + var body = new Hashtable + { + { + "message", new Hashtable + { + { + "toRecipients", new object[] + { + new Hashtable + { + { + "emailAddress", new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + } + } + } + } + } + } + }, + { "saveToSentItems", true } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("message.toRecipients[0].emailAddress.address").Value()); + Assert.True(parsedBody.SelectToken("saveToSentItems").Value()); + } + + [Fact] + public void ShouldSerializePSObjectWrappedPrimitivesAsUnderlyingValues() + { + var body = new Hashtable + { + { "count", PSObject.AsPSObject(42) }, + { "enabled", PSObject.AsPSObject(true) } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal(42, parsedBody.SelectToken("count").Value()); + Assert.True(parsedBody.SelectToken("enabled").Value()); + } + + [Fact] + public void ShouldSerializePSObjectWrappedDictionary() + { + var body = new Hashtable + { + { + "emailAddress", PSObject.AsPSObject(new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + }) + } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("emailAddress.address").Value()); + } + + [Fact] + public void ShouldSerializePSCustomObjectAsJsonObject() + { + var emailAddress = new PSObject(); + emailAddress.Properties.Add(new PSNoteProperty("address", PSObject.AsPSObject("recipient@example.com"))); + var body = new Hashtable + { + { "emailAddress", emailAddress } + }; + + var json = JsonConvert.SerializeObject(body, new PSObjectJsonConverter()); + + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("emailAddress.address").Value()); + } + + [Fact] + public void SetRequestContentShouldSerializeDictionaryWithPSObjectWrappedValues() + { + var cmdlet = new InvokeMgGraphRequest(); + using (var request = new HttpRequestMessage(HttpMethod.Post, + "https://graph.microsoft.com/v1.0/users/sender@example.com/sendMail")) + { + var body = new Hashtable + { + { + "message", new Hashtable + { + { + "toRecipients", new object[] + { + new Hashtable + { + { + "emailAddress", new Hashtable + { + { "address", PSObject.AsPSObject("recipient@example.com") } + } + } + } + } + } + } + }, + { "saveToSentItems", true } + }; + + var contentLength = cmdlet.SetRequestContent(request, body); + + Assert.True(contentLength > 0); + var json = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var parsedBody = JObject.Parse(json); + Assert.Equal("recipient@example.com", + parsedBody.SelectToken("message.toRecipients[0].emailAddress.address").Value()); + } + } + } +} diff --git a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj index e9b9fd1a802..0d5c61186d0 100644 --- a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj +++ b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj @@ -7,7 +7,11 @@ - + + diff --git a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs index 4e16b462486..b93e309f3dd 100644 --- a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs +++ b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs @@ -567,7 +567,7 @@ private async Task GetResponseAsync(HttpClient client, Http /// /// /// - private long SetRequestContent(HttpRequestMessage request, IDictionary content) + internal long SetRequestContent(HttpRequestMessage request, IDictionary content) { if (request == null) { @@ -579,8 +579,10 @@ private long SetRequestContent(HttpRequestMessage request, IDictionary content) throw new ArgumentNullException(nameof(content)); } - // Covert all dictionaries to Json - var body = JsonConvert.SerializeObject(content); + // Convert all dictionaries to Json. + // Unwrap PSObject-wrapped values (e.g. values produced by the PowerShell pipeline) so the + // underlying CLR values are serialized instead of their PSObject wrappers. See https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3654. + var body = JsonConvert.SerializeObject(content, new PSObjectJsonConverter()); return SetRequestContent(request, body); } diff --git a/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs new file mode 100644 index 00000000000..a472d94f364 --- /dev/null +++ b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Newtonsoft.Json; +using System; +using System.Management.Automation; + +namespace Microsoft.Graph.PowerShell.Authentication.Helpers +{ + /// + /// Serializes values by unwrapping them to their underlying base object. + /// PowerShell wraps values that pass through the pipeline (e.g. bare $_ in ForEach-Object) in a + /// . Without this converter, Newtonsoft.Json reflects over the PowerShell + /// adapted members of the wrapper (such as the Chars indexed property on strings) instead of the + /// underlying value and fails with a self-referencing loop error. + /// + internal class PSObjectJsonConverter : JsonConverter + { + public override bool CanRead => false; + + public override bool CanConvert(Type objectType) + { + return typeof(PSObject).IsAssignableFrom(objectType); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var psObject = (PSObject)value; + if (psObject.BaseObject is PSCustomObject) + { + // Pure custom objects (e.g. [pscustomobject]@{ ... }) have no underlying CLR object + // to fall back on; project their properties into a JSON object instead. + writer.WriteStartObject(); + foreach (var property in psObject.Properties) + { + writer.WritePropertyName(property.Name); + serializer.Serialize(writer, property.Value); + } + writer.WriteEndObject(); + } + else + { + // Serialize the underlying CLR object. Nested PSObject-wrapped values (e.g. inside + // dictionaries or collections) are routed back through this converter by the serializer. + serializer.Serialize(writer, psObject.BaseObject); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } +} +