From f1edf9dce8a7ca486ea3fda1de6a73e8f7bf703f Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 15 May 2026 16:58:14 +0800 Subject: [PATCH] Improve CLI error reporting on abp.io auth/license failure - RemoteServiceExceptionHandler: catch all non-cancellation exceptions during error response deserialization (was only catching Newtonsoft JsonReaderException, but the active serializer throws System.Text.Json JsonException, leaking '<' is an invalid start of a value to users) - AbpIoSourceCodeStore: wrap 401/403 responses from abp.io endpoints in CliUsageException with login + license hint, so users get an actionable message instead of HTML-as-JSON parse failure - Add unit tests for RemoteServiceExceptionHandler covering HTML body, valid JSON error, 5xx, and OperationCanceledException propagation --- .../ProjectBuilding/AbpIoSourceCodeStore.cs | 33 ++++- .../RemoteServiceExceptionHandler.cs | 6 +- .../RemoteServiceExceptionHandler_Tests.cs | 135 ++++++++++++++++++ 3 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler_Tests.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs index a594613b44..86ad1f5013 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; @@ -240,12 +241,12 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency using (var response = await client.PostAsync(url, stringContent, _cliHttpClientFactory.GetCancellationToken(TimeSpan.FromMinutes(10)))) { - await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(response); + await EnsureAbpIoSuccessfulResponseAsync(response); var result = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(result).Version; } } - catch (Exception ex) + catch (Exception ex) when (ex is not CliUsageException) { Console.WriteLine("Error occured while getting the latest version from {0} : {1}", url, ex.Message); return null; @@ -273,17 +274,32 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency using (var response = await client.PostAsync(url, stringContent, _cliHttpClientFactory.GetCancellationToken(TimeSpan.FromMinutes(10)))) { - await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(response); + await EnsureAbpIoSuccessfulResponseAsync(response); var result = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(result).Version; } } - catch (Exception) + catch (Exception ex) when (ex is not CliUsageException) { return null; } } + private async Task EnsureAbpIoSuccessfulResponseAsync(HttpResponseMessage responseMessage) + { + if (responseMessage is { StatusCode: HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden }) + { + throw new CliUsageException( + $"Remote server returns '{(int)responseMessage.StatusCode}-{responseMessage.ReasonPhrase}'. " + + "Authentication or license check failed while accessing abp.io. " + + "Please make sure you are logged in with `abp login ` and your ABP commercial license is active and covers the requested version. " + + "You can check your license at https://abp.io/my-organizations" + ); + } + + await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(responseMessage); + } + private async Task IsVersionExists(string templateName, string version) { var url = $"{CliUrls.WwwAbpIo}api/download/all-versions?includePreReleases=true"; @@ -334,7 +350,7 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency _cliHttpClientFactory.GetCancellationToken()); } - await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(responseMessage); + await EnsureAbpIoSuccessfulResponseAsync(responseMessage); var resultAsBytes = await responseMessage.Content.ReadAsByteArrayAsync(); responseMessage.Dispose(); @@ -342,7 +358,12 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency } catch (Exception ex) { - if(ex is UserFriendlyException) + if (ex is CliUsageException) + { + throw; + } + + if (ex is UserFriendlyException) { Logger.LogWarning(ex.Message); throw; diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler.cs index fffdb6291f..41150ec16e 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; -using Newtonsoft.Json; using Volo.Abp.DependencyInjection; using Volo.Abp.Http; using Volo.Abp.Json; @@ -49,12 +48,11 @@ public class RemoteServiceExceptionHandler : IRemoteServiceExceptionHandler, ITr RemoteServiceErrorResponse errorResult; try { - errorResult = _jsonSerializer.Deserialize - ( + errorResult = _jsonSerializer.Deserialize( await responseMessage.Content.ReadAsStringAsync() ); } - catch (JsonReaderException) + catch (Exception ex) when (ex is not OperationCanceledException) { return null; } diff --git a/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler_Tests.cs b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler_Tests.cs new file mode 100644 index 0000000000..85193d3f90 --- /dev/null +++ b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/ProjectBuilding/RemoteServiceExceptionHandler_Tests.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Cli.ProjectBuilding; +using Volo.Abp.Json.SystemTextJson; +using Xunit; + +namespace Volo.Abp.Cli.ProjectBuilding; + +public class RemoteServiceExceptionHandler_Tests +{ + private readonly RemoteServiceExceptionHandler _handler; + + public RemoteServiceExceptionHandler_Tests() + { + var jsonSerializer = new AbpSystemTextJsonSerializer( + Microsoft.Extensions.Options.Options.Create(new AbpSystemTextJsonSerializerOptions()) + ); + _handler = new RemoteServiceExceptionHandler(jsonSerializer); + } + + [Fact] + public async Task EnsureSuccessfulHttpResponseAsync_Should_Not_Throw_On_Success() + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + + await _handler.EnsureSuccessfulHttpResponseAsync(response); + } + + [Fact] + public async Task EnsureSuccessfulHttpResponseAsync_Should_Not_Throw_When_Response_Is_Null() + { + await _handler.EnsureSuccessfulHttpResponseAsync(null); + } + + [Fact] + public async Task Should_Wrap_Html_Body_Without_Json_Parse_Exception() + { + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + ReasonPhrase = "Forbidden", + Content = new StringContent("Forbidden", System.Text.Encoding.UTF8, "text/html") + }; + + var exception = await Should.ThrowAsync(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); + + exception.GetType().ShouldBe(typeof(Exception)); + exception.Message.ShouldContain("403-Forbidden"); + exception.Message.ShouldNotContain("invalid start of a value"); + } + + [Fact] + public async Task Should_Surface_Server_Error_Message_When_Body_Is_Valid_Json() + { + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + ReasonPhrase = "Forbidden", + Content = new StringContent( + "{\"error\":{\"code\":\"LicenseExpired\",\"message\":\"Your ABP license has expired.\"}}", + System.Text.Encoding.UTF8, + "application/json") + }; + + var exception = await Should.ThrowAsync(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); + + exception.Message.ShouldContain("403-Forbidden"); + exception.Message.ShouldContain("LicenseExpired"); + exception.Message.ShouldContain("Your ABP license has expired."); + } + + [Fact] + public async Task Should_Surface_Server_Error_Message_For_5xx_With_Json_Body() + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + ReasonPhrase = "Internal Server Error", + Content = new StringContent( + "{\"error\":{\"code\":\"InternalError\",\"message\":\"Database connection failed\"}}", + System.Text.Encoding.UTF8, + "application/json") + }; + + var exception = await Should.ThrowAsync(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); + + exception.Message.ShouldContain("500-Internal Server Error"); + exception.Message.ShouldContain("InternalError"); + exception.Message.ShouldContain("Database connection failed"); + } + + [Fact] + public async Task GetAbpRemoteServiceErrorAsync_Should_Propagate_OperationCanceledException() + { + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new CanceledStringContent() + }; + + await Should.ThrowAsync( + () => _handler.GetAbpRemoteServiceErrorAsync(response) + ); + } + + [Fact] + public async Task GetAbpRemoteServiceErrorAsync_Should_Return_Null_For_Html_Body() + { + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("", System.Text.Encoding.UTF8, "text/html") + }; + + var result = await _handler.GetAbpRemoteServiceErrorAsync(response); + + result.ShouldBeNull(); + } + + private class CanceledStringContent : HttpContent + { + protected override Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext context) + { + throw new OperationCanceledException(); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } +}