From 9f5a022d5e92cc62a881c3e70fb2d6861e142d35 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 13 Jul 2026 07:45:21 +0200 Subject: [PATCH 1/2] 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 --- .../Helpers/PSObjectJsonConverterTests.cs | 148 ++++++++++++++++++ ...Microsoft.Graph.Authentication.Test.csproj | 6 +- .../Cmdlets/InvokeMgGraphRequest.cs | 8 +- .../Helpers/PSObjectJsonConverter.cs | 55 +++++++ 4 files changed, 213 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 ccaa28e8fb4..2dda94b996d 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..6f8c5eba664 --- /dev/null +++ b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------------ +// 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(); + } + } +} From 95a186cec4d4d28ab16d473111f09294b11f362a Mon Sep 17 00:00:00 2001 From: Ramses Sanchez-Hernandez <63934382+ramsessanchez@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:42:21 -0700 Subject: [PATCH 2/2] Add newline at end of PSObjectJsonConverter.cs add space to kick off CI --- .../Authentication/Helpers/PSObjectJsonConverter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs index 6f8c5eba664..a472d94f364 100644 --- a/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs +++ b/src/Authentication/Authentication/Helpers/PSObjectJsonConverter.cs @@ -53,3 +53,4 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } } } +