From 3f5c291648068459cbfc8b4c7c1ce370cbbf0b1a Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 29 Jun 2026 13:57:32 +0200 Subject: [PATCH] feat: surface the environment URL and sign-in fix on auth failure --- docs/output-contract.md | 5 +- .../ExceptionHelpers.cs | 13 +++ .../EnvironmentAuthRequiredException.cs | 49 +++++++++++ src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs | 16 ++++ .../Runtime/DataverseAccessTokenService.cs | 14 ++-- .../Abstractions/ExceptionHelpersTests.cs | 44 ++++++++++ .../Headless/AuthRequiredExitCodeTests.cs | 69 +++++++++++++++ .../EnvironmentAuthRequiredExceptionTests.cs | 83 +++++++++++++++++++ 8 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 src/TALXIS.CLI.Core/Headless/EnvironmentAuthRequiredException.cs create mode 100644 tests/TALXIS.CLI.Tests/Abstractions/ExceptionHelpersTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Config/Headless/AuthRequiredExitCodeTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Config/Headless/EnvironmentAuthRequiredExceptionTests.cs diff --git a/docs/output-contract.md b/docs/output-contract.md index cd214551..0814460a 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -41,8 +41,9 @@ txc env entity list --format text | `0` (`ExitSuccess`) | Operation completed successfully | Default success path | | `1` (`ExitError`) | Runtime/operational error | Service call failed, network error, unexpected exception | | `2` (`ExitValidationError`) | Input validation error or resource not found | Bad arguments, missing required input, entity not found | +| `3` (`ExitAuthRequired`) | Authentication required | A command touched a live environment but there is no usable sign-in (missing token, expired cache, or a headless context that forbids interactive login). The message names the environment URL and the `txc config profile create --url ` fix to run manually. | -The base class `TxcLeafCommand.RunAsync()` catches unhandled exceptions and returns `ExitError` (1) automatically. Commands only need explicit exit code handling for validation errors (2). +The base class `TxcLeafCommand.RunAsync()` catches unhandled exceptions and returns `ExitError` (1) automatically. It maps `EnvironmentAuthRequiredException` (anywhere in the inner-exception chain) to `ExitAuthRequired` (3). Commands only need explicit exit code handling for validation errors (2). ## Command implementation pattern @@ -81,7 +82,7 @@ public class WidgetListCliCommand : ProfiledCliCommand - **`OutputContext` setup** — applies the format flag with TTY auto-detection - **Standardized try/catch** — catches `ConfigurationResolutionException`, `OperationCanceledException`, and general exceptions - **`ILogger` requirement** — `protected abstract ILogger Logger` ensures every command has logging -- **Exit code constants** — `ExitSuccess` (0), `ExitError` (1), `ExitValidationError` (2) +- **Exit code constants** — `ExitSuccess` (0), `ExitError` (1), `ExitValidationError` (2), `ExitAuthRequired` (3) ### What commands must NOT do diff --git a/src/TALXIS.CLI.Abstractions/ExceptionHelpers.cs b/src/TALXIS.CLI.Abstractions/ExceptionHelpers.cs index bc0efe8b..974fdddc 100644 --- a/src/TALXIS.CLI.Abstractions/ExceptionHelpers.cs +++ b/src/TALXIS.CLI.Abstractions/ExceptionHelpers.cs @@ -17,4 +17,17 @@ public static Exception GetInnermostException(Exception ex) ex = ex.InnerException; return ex; } + + /// + /// Returns the first exception of type in the + /// inner-exception chain (including ), or null. + /// + public static T? FindInChain(Exception ex) where T : Exception + { + for (Exception? current = ex; current is not null; current = current.InnerException) + { + if (current is T match) return match; + } + return null; + } } diff --git a/src/TALXIS.CLI.Core/Headless/EnvironmentAuthRequiredException.cs b/src/TALXIS.CLI.Core/Headless/EnvironmentAuthRequiredException.cs new file mode 100644 index 00000000..f4d19e8f --- /dev/null +++ b/src/TALXIS.CLI.Core/Headless/EnvironmentAuthRequiredException.cs @@ -0,0 +1,49 @@ +namespace TALXIS.CLI.Core.Headless; + +/// +/// Auth failure that carries the target environment URL and renders the exact +/// txc config profile create --url <url> remedy the user must run manually. +/// +public sealed class EnvironmentAuthRequiredException : Exception +{ + public string EnvironmentUrl { get; } + public string? HeadlessReason { get; } + +#pragma warning disable RS0030 // domain exception - inheriting from Exception is intentional + public EnvironmentAuthRequiredException(string environmentUrl, string? headlessReason = null, Exception? innerException = null) + : base(BuildMessage(environmentUrl, headlessReason), innerException) +#pragma warning restore RS0030 + { + EnvironmentUrl = environmentUrl; + HeadlessReason = headlessReason; + } + + public static string BuildMessage(string environmentUrl, string? headlessReason) + { + var url = NormalizeUrl(environmentUrl); + var target = url ?? "the target environment"; + + var lines = new List + { + $"Action required - sign in to {target} before retrying:", + url is not null + ? $" txc config profile create --url {url}" + : " txc config profile create --url ", + "Run that command manually in an interactive terminal (browser required), then retry.", + }; + + var why = "txc never signs in on its own: interactive sign-in needs a human in the loop, so agents and non-interactive runs are blocked from it."; + if (!string.IsNullOrWhiteSpace(headlessReason)) why += $" This run is non-interactive ({headlessReason})."; + lines.Add(why); + + return string.Join(Environment.NewLine, lines); + } + + private static string? NormalizeUrl(string? environmentUrl) + { + if (string.IsNullOrWhiteSpace(environmentUrl)) return null; + return Uri.TryCreate(environmentUrl, UriKind.Absolute, out var uri) + ? uri.GetLeftPart(UriPartial.Authority) + "/" + : environmentUrl.TrimEnd('/') + "/"; + } +} diff --git a/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs b/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs index 5451f9f6..549f37b1 100644 --- a/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs +++ b/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs @@ -36,6 +36,8 @@ public abstract class TxcLeafCommand protected const int ExitError = 1; /// Exit code: input validation error or resource not found. protected const int ExitValidationError = 2; + /// Exit code: authentication required — the user must sign in to the target environment before retrying. + protected const int ExitAuthRequired = 3; [CliOption( Name = "--format", @@ -105,6 +107,20 @@ public async Task RunAsync() exitCode = await ExecuteAsync().ConfigureAwait(false); return exitCode; } + catch (Exception ex) when ( + ExceptionHelpers.FindInChain(ex) is not null) + { + exitCode = ExitAuthRequired; + + // Surface the auth exception's own message, not the innermost MSAL error. + var authEx = ExceptionHelpers.FindInChain(ex)!; + + scope.SetError(exitCode, "auth", authEx.Message); + OutputFormatter.WriteResult("failed", authEx.Message, exitCode: exitCode); + Logger.LogError(authEx, "Authentication required: {Error}", authEx.Message); + LogSupportInfo(); + return exitCode; + } catch (Exception ex) when (ex is Abstractions.ConfigurationResolutionException or ArgumentException) { exitCode = ExitValidationError; diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseAccessTokenService.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseAccessTokenService.cs index 54cf3cb1..2088e2b4 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseAccessTokenService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Runtime/DataverseAccessTokenService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Identity.Client; using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Headless; using TALXIS.CLI.Core.Identity; using TALXIS.CLI.Core.Model; using TALXIS.CLI.Platform.Dataverse.Runtime.Scopes; @@ -137,9 +138,9 @@ private async Task AcquirePublicClientSilentAsync( if (account is null) { - throw new InvalidOperationException( - $"No cached sign-in found for credential '{credential.Id}'. " + - "Run 'txc config auth login' and retry."); + throw new EnvironmentAuthRequiredException( + connection.EnvironmentUrl, + _headless.IsHeadless ? _headless.Reason : null); } try @@ -184,9 +185,10 @@ private async Task AcquirePublicClientSilentAsync( } } - throw new InvalidOperationException( - $"Cached token for '{credential.Id}' expired or is missing consent. " + - "Run 'txc config auth login' and retry.", ex); + throw new EnvironmentAuthRequiredException( + connection.EnvironmentUrl, + _headless.IsHeadless ? _headless.Reason : null, + ex); } } diff --git a/tests/TALXIS.CLI.Tests/Abstractions/ExceptionHelpersTests.cs b/tests/TALXIS.CLI.Tests/Abstractions/ExceptionHelpersTests.cs new file mode 100644 index 00000000..c5e9d555 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Abstractions/ExceptionHelpersTests.cs @@ -0,0 +1,44 @@ +using System; +using TALXIS.CLI.Abstractions; +using Xunit; + +namespace TALXIS.CLI.Tests.Abstractions; + +public class ExceptionHelpersTests +{ + private sealed class MarkerException : Exception + { + public MarkerException(string message) : base(message) { } + } + + [Fact] + public void FindInChain_ReturnsMatch_WhenTopLevel() + { + var ex = new MarkerException("top"); + + var found = ExceptionHelpers.FindInChain(ex); + + Assert.Same(ex, found); + } + + [Fact] + public void FindInChain_ReturnsMatch_WhenWrapped() + { + var marker = new MarkerException("inner"); + var wrapped = new InvalidOperationException("outer", marker); + + var found = ExceptionHelpers.FindInChain(wrapped); + + Assert.Same(marker, found); + } + + [Fact] + public void FindInChain_ReturnsNull_WhenAbsent() + { + var ex = new InvalidOperationException("outer", new ArgumentException("inner")); + + var found = ExceptionHelpers.FindInChain(ex); + + Assert.Null(found); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Headless/AuthRequiredExitCodeTests.cs b/tests/TALXIS.CLI.Tests/Config/Headless/AuthRequiredExitCodeTests.cs new file mode 100644 index 00000000..6224ffcf --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Headless/AuthRequiredExitCodeTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Tests.Config.Commands; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Headless; + +[Collection("Sequential")] +public class AuthRequiredExitCodeTests +{ + private sealed class ThrowingCommand : TxcLeafCommand + { + private readonly Exception _toThrow; + public ThrowingCommand(Exception toThrow) { _toThrow = toThrow; } + protected override ILogger Logger { get; } = NullLogger.Instance; + protected override Task ExecuteAsync() => throw _toThrow; + } + + [Fact] + public async Task EnvironmentAuthRequired_MapsToExitCode3() + { + using var _ = new CommandTestHost(); + var cmd = new ThrowingCommand( + new EnvironmentAuthRequiredException("https://devbox.crm4.dynamics.com")); + + var exit = await cmd.RunAsync(); + + Assert.Equal(3, exit); + } + + [Fact] + public async Task WrappedEnvironmentAuthRequired_MapsToExitCode3() + { + using var _ = new CommandTestHost(); + var inner = new EnvironmentAuthRequiredException("https://devbox.crm4.dynamics.com"); + var cmd = new ThrowingCommand(new InvalidOperationException("service wrapper", inner)); + + var exit = await cmd.RunAsync(); + + Assert.Equal(3, exit); + } + + [Fact] + public async Task HeadlessAuthRequired_StaysExitCode1() + { + // Exit 3 is scoped to EnvironmentAuthRequiredException only. + using var _ = new CommandTestHost(); + var cmd = new ThrowingCommand( + new HeadlessAuthRequiredException(CredentialKind.InteractiveBrowser, "CI=true")); + + var exit = await cmd.RunAsync(); + + Assert.Equal(1, exit); + } + + [Fact] + public async Task UnrelatedError_StillMapsToExitCode1() + { + using var _ = new CommandTestHost(); + var cmd = new ThrowingCommand(new InvalidOperationException("boom")); + + var exit = await cmd.RunAsync(); + + Assert.Equal(1, exit); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Headless/EnvironmentAuthRequiredExceptionTests.cs b/tests/TALXIS.CLI.Tests/Config/Headless/EnvironmentAuthRequiredExceptionTests.cs new file mode 100644 index 00000000..5589e198 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Headless/EnvironmentAuthRequiredExceptionTests.cs @@ -0,0 +1,83 @@ +using TALXIS.CLI.Core.Headless; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Headless; + +public class EnvironmentAuthRequiredExceptionTests +{ + private const string Url = "https://devbox-2782.crm4.dynamics.com"; + + [Fact] + public void Message_NamesEnvironmentUrl_AndFixCommand() + { + var ex = new EnvironmentAuthRequiredException(Url); + + Assert.Contains("https://devbox-2782.crm4.dynamics.com/", ex.Message); + Assert.Contains("txc config profile create --url https://devbox-2782.crm4.dynamics.com/", ex.Message); + } + + [Fact] + public void Message_TellsUserToRunManuallyInInteractiveTerminal() + { + var ex = new EnvironmentAuthRequiredException(Url); + + Assert.Contains("interactive terminal", ex.Message); + Assert.Contains("human in the loop", ex.Message); + } + + [Fact] + public void Message_NoEmDash() + { + var ex = new EnvironmentAuthRequiredException(Url); + + Assert.DoesNotContain('—', ex.Message); + } + + [Fact] + public void Message_AppendsHeadlessReason_WhenProvided() + { + var ex = new EnvironmentAuthRequiredException(Url, headlessReason: "CI=true"); + + Assert.Contains("CI=true", ex.Message); + Assert.Equal("CI=true", ex.HeadlessReason); + } + + [Fact] + public void Message_OmitsHeadlessReason_WhenInteractive() + { + var ex = new EnvironmentAuthRequiredException(Url, headlessReason: null); + + Assert.DoesNotContain("non-interactive (", ex.Message); + Assert.Null(ex.HeadlessReason); + } + + [Theory] + [InlineData("https://contoso.crm4.dynamics.com")] + [InlineData("https://contoso.crm4.dynamics.com/")] + [InlineData("https://contoso.crm4.dynamics.com/main.aspx")] + public void Message_NormalizesUrlToAuthorityWithTrailingSlash(string input) + { + var ex = new EnvironmentAuthRequiredException(input); + + Assert.Contains("https://contoso.crm4.dynamics.com/", ex.Message); + Assert.DoesNotContain("main.aspx", ex.Message); + } + + [Fact] + public void Message_FallsBack_WhenUrlMissing() + { + var ex = new EnvironmentAuthRequiredException(environmentUrl: ""); + + Assert.Contains("the target environment", ex.Message); + Assert.Contains("--url ", ex.Message); + } + + [Fact] + public void Preserves_InnerException() + { + var inner = new InvalidOperationException("token expired"); + var ex = new EnvironmentAuthRequiredException(Url, innerException: inner); + + Assert.Same(inner, ex.InnerException); + } +}