From b53d75031791317eabaa79fac79fb9f84e1b285d Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Tue, 28 Apr 2026 10:30:17 +1000 Subject: [PATCH 1/5] Fix #19605: Surface service error details for role assignment operations --- ...zationErrorResponseExceptionHelperTests.cs | 103 ++++++++++++++++++ ...thorizationErrorResponseExceptionHelper.cs | 63 +++++++++++ .../AuthorizationClient.cs | 31 +++++- 3 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs create mode 100644 src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs diff --git a/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs new file mode 100644 index 000000000000..ae4947d820e3 --- /dev/null +++ b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs @@ -0,0 +1,103 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using FluentAssertions; +using Microsoft.Azure.Commands.Common.Exceptions; +using Microsoft.Azure.Commands.Resources.Helper; +using Microsoft.Azure.Management.Authorization.Models; +using Microsoft.Rest; +using Microsoft.WindowsAzure.Commands.ScenarioTest; +using System.Net.Http; +using Xunit; + +namespace Microsoft.Azure.Commands.Resources.Test.UnitTests +{ + public class AuthorizationErrorResponseExceptionHelperTests + { + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithStructuredBody_IncludesCodeAndMessage() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'Conflict'") + { + Body = new ErrorResponse(new ErrorDetail( + code: "RoleAssignmentExists", + message: "The role assignment already exists.")) + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Should().BeOfType(); + result.Message.Should().Contain("Operation returned an invalid status code 'Conflict'"); + result.Message.Should().Contain("RoleAssignmentExists"); + result.Message.Should().Contain("The role assignment already exists."); + result.InnerException.Should().Be(ex); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithRawResponseContentOnly_IncludesResponseContent() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'NotFound'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.NotFound), + "{\"error\":{\"code\":\"SubscriptionNotFound\",\"message\":\"The subscription could not be found.\"}}") + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Should().BeOfType(); + result.Message.Should().Contain("Operation returned an invalid status code 'NotFound'"); + result.Message.Should().Contain("SubscriptionNotFound"); + result.InnerException.Should().Be(ex); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithNoBodyOrResponse_FallsBackToOriginalMessage() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'"); + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Should().BeOfType(); + result.Message.Should().Be("Operation returned an invalid status code 'BadRequest'"); + result.InnerException.Should().Be(ex); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_PreservesRequestAndResponse() + { + var request = new HttpRequestMessageWrapper( + new HttpRequestMessage(HttpMethod.Put, "https://management.azure.com/test"), + "{}"); + var response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.Conflict), + "{}"); + var ex = new ErrorResponseException("Conflict") + { + Request = request, + Response = response, + Body = new ErrorResponse(new ErrorDetail(code: "X", message: "Y")) + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Request.Should().Be(request); + result.Response.Should().Be(response); + } + } +} diff --git a/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs new file mode 100644 index 000000000000..8586dddc9ea1 --- /dev/null +++ b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs @@ -0,0 +1,63 @@ +// ---------------------------------------------------------------------------------- +// +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +using Microsoft.Azure.Commands.Common.Exceptions; +using Microsoft.Azure.Management.Authorization.Models; + +namespace Microsoft.Azure.Commands.Resources.Helper +{ + /// + /// Helper class to convert from the Authorization SDK + /// into with descriptive error messages. + /// The SDK-generated only contains the HTTP status code + /// in its message; the actual service error details live on ex.Body.Error or + /// ex.Response.Content. This helper surfaces those details to the user. + /// + internal static class AuthorizationErrorResponseExceptionHelper + { + /// + /// Creates an from an , + /// extracting the service error details from either the structured Body or the raw response content. + /// + /// The original . + /// An with the descriptive error message. + internal static AzPSCloudException CreateDescriptiveException(ErrorResponseException ex) + { + string message; + string desensitizedMessage; + + if (ex.Body?.Error != null) + { + message = $"{ex.Message}. {ex.Body.Error.Code}: {ex.Body.Error.Message}"; + desensitizedMessage = ex.Body.Error.Code; + } + else if (ex.Response?.Content != null) + { + message = $"{ex.Message}. Response: {ex.Response.Content}"; + desensitizedMessage = ex.Message; + } + else + { + message = ex.Message; + desensitizedMessage = ex.Message; + } + + return new AzPSCloudException(message, desensitizedMessage, ex) + { + Request = ex.Request, + Response = ex.Response, + }; + } + } +} diff --git a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs index 937d72fc30a4..db408cee4ab6 100644 --- a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs +++ b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs @@ -15,6 +15,8 @@ using Microsoft.Azure.Commands.ActiveDirectory; using Microsoft.Azure.Commands.Common.Authentication; using Microsoft.Azure.Commands.Common.Authentication.Abstractions; +using Microsoft.Azure.Commands.Common.Exceptions; +using Microsoft.Azure.Commands.Resources.Helper; using Microsoft.Azure.Management.Authorization; using Microsoft.Azure.Management.Authorization.Models; using Microsoft.Rest.Azure.OData; @@ -81,8 +83,15 @@ public PSRoleDefinition GetRoleDefinition(Guid roleId, string scope) public IEnumerable FilterRoleDefinitions(string name, string scope, ulong first = ulong.MaxValue, ulong skip = 0) { ODataQuery odataFilter = new ODataQuery(item => item.RoleName == name); - return AuthorizationManagementClient.RoleDefinitions.List(scope, odataFilter) - .Select(r => r.ToPSRoleDefinition()); + try + { + return AuthorizationManagementClient.RoleDefinitions.List(scope, odataFilter) + .Select(r => r.ToPSRoleDefinition()); + } + catch (ErrorResponseException ex) + { + throw AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + } } public IEnumerable FilterRoleDefinitions(FilterRoleDefinitionOptions options) @@ -171,7 +180,14 @@ public IEnumerable FilterRoleDefinitionsByCustom(string scope, ConditionVersion = parameters.ConditionVersion }; - return AuthorizationManagementClient.RoleAssignments.Create(parameters.Scope, roleAssignmentId.ToString(), createParameters).ToPSRoleAssignment(this, ActiveDirectoryClient); + try + { + return AuthorizationManagementClient.RoleAssignments.Create(parameters.Scope, roleAssignmentId.ToString(), createParameters).ToPSRoleAssignment(this, ActiveDirectoryClient); + } + catch (ErrorResponseException ex) + { + throw AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + } } /// @@ -377,7 +393,14 @@ public PSRoleAssignment UpdateRoleAssignment(PSRoleAssignment roleAssignment) ConditionVersion = ConditionVersion }; - return AuthorizationManagementClient.RoleAssignments.Create(scope, roleAssignmentId, createParameters).ToPSRoleAssignment(this, ActiveDirectoryClient); + try + { + return AuthorizationManagementClient.RoleAssignments.Create(scope, roleAssignmentId, createParameters).ToPSRoleAssignment(this, ActiveDirectoryClient); + } + catch (ErrorResponseException ex) + { + throw AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + } } /// From 5d43e1fd4e352c797b81aea0260ca61a55e047cf Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Tue, 28 Apr 2026 10:31:10 +1000 Subject: [PATCH 2/5] Fix #19605: Replace dead Hyak exception handler in role definition operations --- src/Resources/Resources/ChangeLog.md | 1 + .../Models.Authorization/AuthorizationClient.cs | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Resources/Resources/ChangeLog.md b/src/Resources/Resources/ChangeLog.md index 9cc752ff96a1..42ae566218f4 100644 --- a/src/Resources/Resources/ChangeLog.md +++ b/src/Resources/Resources/ChangeLog.md @@ -19,6 +19,7 @@ --> ## Upcoming Release +* Improved error messages for role assignment and role definition operations to include the underlying service error code and message instead of just the HTTP status code. [#19605] [#19374] * Added `-PrincipalId` and `-PrincipalType` parameters to `New-AzDenyAssignment` to support per-principal deny assignments targeting a specific User or ServicePrincipal, in addition to the existing Everyone mode. * Added `New-AzDenyAssignment` cmdlet for creating user-assigned deny assignments using the `2024-07-01-preview` API. Deny assignments allow denying specific write, delete, and action operations to all principals at a given scope while excluding specified principals. * Added `Remove-AzDenyAssignment` cmdlet for removing user-assigned deny assignments by ID, name and scope, or pipeline input. diff --git a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs index db408cee4ab6..5f6f5cfc659b 100644 --- a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs +++ b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs @@ -823,15 +823,14 @@ private PSRoleDefinition CreateOrUpdateRoleDefinition(Guid roleDefinitionId, PSR roleDef = AuthorizationManagementClient.RoleDefinitions.CreateOrUpdate( roleDefinition.AssignableScopes.First(), roleDefinitionId.ToString(), parameters).ToPSRoleDefinition(); } - catch (Hyak.Common.CloudException ce) + catch (ErrorResponseException ex) { - if (ce.Response.StatusCode == HttpStatusCode.Unauthorized && - ce.Error.Code.Equals("TenantNotAllowed", StringComparison.InvariantCultureIgnoreCase)) + if (ex.Response?.StatusCode == HttpStatusCode.Unauthorized && + ex.Body?.Error?.Code?.Equals("TenantNotAllowed", StringComparison.InvariantCultureIgnoreCase) == true) { throw new InvalidOperationException("The tenant is not currently authorized to create/update Custom role definition. Please refer to http://aka.ms/customrolespreview for more details"); } - - throw; + throw AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); } return roleDef; From aaac5de4c5b13e00d7cacd914f4a18ab5618e954 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Tue, 28 Apr 2026 16:45:17 +1000 Subject: [PATCH 3/5] Fixes as per PR comments --- ...zationErrorResponseExceptionHelperTests.cs | 73 +++++++++++++++++++ ...thorizationErrorResponseExceptionHelper.cs | 38 +++++++++- .../AuthorizationClient.cs | 1 - 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs index ae4947d820e3..48fd85c33bb2 100644 --- a/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs +++ b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs @@ -99,5 +99,78 @@ public void CreateDescriptiveException_PreservesRequestAndResponse() result.Request.Should().Be(request); result.Response.Should().Be(response); } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithParseableResponseContent_FormatsCodeAndDetail() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'NotFound'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.NotFound), + "{\"error\":{\"code\":\"SubscriptionNotFound\",\"message\":\"The subscription could not be found.\"}}") + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + // Should produce the same ". code: detail" shape as the structured-body branch, + // not dump the raw JSON blob. + result.Message.Should().Contain("SubscriptionNotFound: The subscription could not be found."); + result.Message.Should().NotContain("Response: {"); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithMalformedResponseContent_FallsBackToRawContent() + { + const string malformed = "this is not json {"; + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest), + malformed) + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().Contain("Operation returned an invalid status code 'BadRequest'"); + result.Message.Should().Contain($"Response: {malformed}"); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithJsonMissingErrorObject_FallsBackToRawContent() + { + const string content = "{\"unrelated\":\"value\"}"; + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest), + content) + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().Contain($"Response: {content}"); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithBothBodyAndResponseContent_PrefersStructuredBody() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'Conflict'") + { + Body = new ErrorResponse(new ErrorDetail(code: "BodyCode", message: "Body message.")), + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.Conflict), + "{\"error\":{\"code\":\"ResponseCode\",\"message\":\"Response message.\"}}") + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().Contain("BodyCode"); + result.Message.Should().Contain("Body message."); + result.Message.Should().NotContain("ResponseCode"); + } } } diff --git a/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs index 8586dddc9ea1..d426bcb2cf65 100644 --- a/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs +++ b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs @@ -14,6 +14,8 @@ using Microsoft.Azure.Commands.Common.Exceptions; using Microsoft.Azure.Management.Authorization.Models; +using Newtonsoft.Json.Linq; +using System; namespace Microsoft.Azure.Commands.Resources.Helper { @@ -26,6 +28,10 @@ namespace Microsoft.Azure.Commands.Resources.Helper /// internal static class AuthorizationErrorResponseExceptionHelper { + // Telemetry-safe placeholder used when no service-supplied error code is available. + // Avoids any risk of leaking PII via ex.Message if the SDK template ever changes. + private const string UnknownErrorCode = "UnknownAuthorizationError"; + /// /// Creates an from an , /// extracting the service error details from either the structured Body or the raw response content. @@ -42,15 +48,14 @@ internal static AzPSCloudException CreateDescriptiveException(ErrorResponseExcep message = $"{ex.Message}. {ex.Body.Error.Code}: {ex.Body.Error.Message}"; desensitizedMessage = ex.Body.Error.Code; } - else if (ex.Response?.Content != null) + else if (!string.IsNullOrEmpty(ex.Response?.Content)) { - message = $"{ex.Message}. Response: {ex.Response.Content}"; - desensitizedMessage = ex.Message; + (message, desensitizedMessage) = ParseErrorContent(ex.Message, ex.Response.Content); } else { message = ex.Message; - desensitizedMessage = ex.Message; + desensitizedMessage = UnknownErrorCode; } return new AzPSCloudException(message, desensitizedMessage, ex) @@ -59,5 +64,30 @@ internal static AzPSCloudException CreateDescriptiveException(ErrorResponseExcep Response = ex.Response, }; } + + // Builds the (user-facing message, telemetry-safe code) pair from a raw response body. + // On a successful parse of the standard Azure error JSON shape + // ({ "error": { "code": "...", "message": "..." } }) we surface code+message; + // otherwise we fall back to embedding the raw content with an UnknownErrorCode tag. + private static (string Message, string Desensitized) ParseErrorContent(string original, string content) + { + try + { + var error = JObject.Parse(content)["error"] as JObject; + var code = error?.Value("code"); + var detail = error?.Value("message"); + + if (!string.IsNullOrEmpty(code) || !string.IsNullOrEmpty(detail)) + { + return ($"{original}. {code}: {detail}", string.IsNullOrEmpty(code) ? UnknownErrorCode : code); + } + } + catch (Exception) + { + // Fall through to raw-content fallback. + } + + return ($"{original}. Response: {content}", UnknownErrorCode); + } } } diff --git a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs index 5f6f5cfc659b..eb20af504635 100644 --- a/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs +++ b/src/Resources/Resources/Models.Authorization/AuthorizationClient.cs @@ -15,7 +15,6 @@ using Microsoft.Azure.Commands.ActiveDirectory; using Microsoft.Azure.Commands.Common.Authentication; using Microsoft.Azure.Commands.Common.Authentication.Abstractions; -using Microsoft.Azure.Commands.Common.Exceptions; using Microsoft.Azure.Commands.Resources.Helper; using Microsoft.Azure.Management.Authorization; using Microsoft.Azure.Management.Authorization.Models; From f54bc431823405954f292dfba139012312a01c71 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Wed, 29 Apr 2026 11:26:47 +1000 Subject: [PATCH 4/5] Test fix as per code changes --- .../Resources.Test/ScenarioTests/RoleAssignmentTests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Resources/Resources.Test/ScenarioTests/RoleAssignmentTests.ps1 b/src/Resources/Resources.Test/ScenarioTests/RoleAssignmentTests.ps1 index e276db277d9e..b45cccbfe9b0 100644 --- a/src/Resources/Resources.Test/ScenarioTests/RoleAssignmentTests.ps1 +++ b/src/Resources/Resources.Test/ScenarioTests/RoleAssignmentTests.ps1 @@ -918,12 +918,12 @@ function Test-CreateRAWhenIdNotExist $RoleDefinitionId = "acdd72a7-3385-48ef-bd42-f606fba81ae7" $PrincipalId ="6d764d35-6b3b-49ea-83f8-5c223b56eac5" $Scope = '/subscriptions/4004a9fd-d58e-48dc-aeb2-4a4aec58606f' - $ExpectedError = 'Exception calling "ExecuteCmdlet" with "0" argument(s): "Operation returned an invalid status code ''BadRequest''"' + $ExpectedError = "PrincipalNotFound: Principal $($PrincipalId.Replace('-','')) does not exist in the directory" #When $function = { New-AzRoleAssignmentWithId -ObjectId $PrincipalId -Scope $Scope -RoleDefinitionId $RoleDefinitionId -RoleAssignmentId 0f7b6fb6-a5f4-4046-83eb-dfd93c5e4b72 } - Assert-Throws $function $ExpectedError + Assert-ThrowsContains $function $ExpectedError } <# From 1d1edfa42c1884ed6460e61826acb11512a04559 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Wed, 29 Apr 2026 13:33:05 +1000 Subject: [PATCH 5/5] Changes as per comments --- ...zationErrorResponseExceptionHelperTests.cs | 59 +++++++++++++++++++ ...thorizationErrorResponseExceptionHelper.cs | 23 +++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs index 48fd85c33bb2..34fe1520fc21 100644 --- a/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs +++ b/src/Resources/Resources.Test/UnitTests/AuthorizationErrorResponseExceptionHelperTests.cs @@ -134,7 +134,10 @@ public void CreateDescriptiveException_WithMalformedResponseContent_FallsBackToR var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); result.Message.Should().Contain("Operation returned an invalid status code 'BadRequest'"); + // Short content is embedded as-is (after whitespace collapsing) in the fallback. result.Message.Should().Contain($"Response: {malformed}"); + // Full body remains accessible on the wrapped Response for debugging. + result.Response.Content.Should().Be(malformed); } [Fact] @@ -154,6 +157,28 @@ public void CreateDescriptiveException_WithJsonMissingErrorObject_FallsBackToRaw result.Message.Should().Contain($"Response: {content}"); } + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithLargeUnparseableContent_TruncatesInMessageButPreservesFullBody() + { + // 1500 chars, well over the 500-char display limit, with embedded newlines. + var largeContent = new string('a', 700) + "\n\n" + new string('b', 800); + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest), + largeContent) + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().Contain("... (truncated)"); + result.Message.Should().NotContain("\n\n"); // whitespace collapsed + result.Message.Length.Should().BeLessThan(largeContent.Length); + // Full body must remain available on the wrapped response for debugging. + result.Response.Content.Should().Be(largeContent); + } + [Fact] [Trait(Category.AcceptanceType, Category.CheckIn)] public void CreateDescriptiveException_WithBothBodyAndResponseContent_PrefersStructuredBody() @@ -172,5 +197,39 @@ public void CreateDescriptiveException_WithBothBodyAndResponseContent_PrefersStr result.Message.Should().Contain("Body message."); result.Message.Should().NotContain("ResponseCode"); } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithCodeOnlyInResponseContent_OmitsTrailingColon() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest), + "{\"error\":{\"code\":\"OnlyCode\"}}") + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().EndWith("OnlyCode"); + result.Message.Should().NotContain("OnlyCode:"); + } + + [Fact] + [Trait(Category.AcceptanceType, Category.CheckIn)] + public void CreateDescriptiveException_WithMessageOnlyInResponseContent_OmitsLeadingColon() + { + var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest), + "{\"error\":{\"message\":\"Only the message was provided.\"}}") + }; + + var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex); + + result.Message.Should().EndWith("Only the message was provided."); + result.Message.Should().NotContain(": Only the message"); + } } } diff --git a/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs index d426bcb2cf65..52f3520734cb 100644 --- a/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs +++ b/src/Resources/Resources/Helper/AuthorizationErrorResponseExceptionHelper.cs @@ -16,6 +16,7 @@ using Microsoft.Azure.Management.Authorization.Models; using Newtonsoft.Json.Linq; using System; +using System.Text.RegularExpressions; namespace Microsoft.Azure.Commands.Resources.Helper { @@ -32,6 +33,10 @@ internal static class AuthorizationErrorResponseExceptionHelper // Avoids any risk of leaking PII via ex.Message if the SDK template ever changes. private const string UnknownErrorCode = "UnknownAuthorizationError"; + // Maximum number of characters of raw response content embedded in the user-facing + // exception message. The full body remains available on ex.Response.Content for debugging. + private const int MaxRawContentLength = 500; + /// /// Creates an from an , /// extracting the service error details from either the structured Body or the raw response content. @@ -79,7 +84,10 @@ private static (string Message, string Desensitized) ParseErrorContent(string or if (!string.IsNullOrEmpty(code) || !string.IsNullOrEmpty(detail)) { - return ($"{original}. {code}: {detail}", string.IsNullOrEmpty(code) ? UnknownErrorCode : code); + var suffix = !string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(detail) + ? $"{code}: {detail}" + : (code ?? detail); + return ($"{original}. {suffix}", string.IsNullOrEmpty(code) ? UnknownErrorCode : code); } } catch (Exception) @@ -87,7 +95,18 @@ private static (string Message, string Desensitized) ParseErrorContent(string or // Fall through to raw-content fallback. } - return ($"{original}. Response: {content}", UnknownErrorCode); + return ($"{original}. Response: {TruncateForDisplay(content)}", UnknownErrorCode); + } + + // Collapses runs of whitespace and truncates overly long bodies so a multi-line/large + // service response doesn't flood the console. The full body is still available via + // AzPSCloudException.Response.Content for debugging. + private static string TruncateForDisplay(string content) + { + var collapsed = Regex.Replace(content.Trim(), @"\s+", " "); + return collapsed.Length <= MaxRawContentLength + ? collapsed + : collapsed.Substring(0, MaxRawContentLength) + "... (truncated)"; } } }