diff --git a/docs/eppie-cli-agent-skill.md b/docs/eppie-cli-agent-skill.md index fbeab90a..2935a253 100644 --- a/docs/eppie-cli-agent-skill.md +++ b/docs/eppie-cli-agent-skill.md @@ -173,6 +173,12 @@ JSON responses use a normalized envelope: In `--non-interactive=true --output=json` mode, structured responses are emitted on `stdout` without a preceding stack trace on `stderr` for handled command failures such as `unhandledException`. +In that mode the prompts are gone, so before each value it reads as a single line the run emits `{"type":"status","code":"inputRequired","data":{"input":""}}`, naming the value being read. A command therefore emits several envelopes even in the ordinary case: parse `stdout` line by line and act on the last one. Text output carries no announcements. The message body of `send` is the exception: it is read to end-of-stream rather than as a single line and is not announced, so write it straight after the vault password and close `stdin`. + +`data.input` is one of nine names: `vaultPassword` for any run with `--unlock-password-stdin=true` and for `open`; `newVaultPassword` for `init` and `restore`; `seedPhrase` and `restorePath` for `restore`; `accountAddress` and `accountPassword` for `add-account -t proton` without `--input-json-stdin`, followed in that same run by `twoFactorCode`, `mailboxPassword` and `humanVerificationToken` whenever Proton asks for them. Nothing else is announced -- `add-account -t email` takes its values as one payload or refuses, and a vault password is never confirmed in this mode. + +`--output=json` requires `--non-interactive=true`. Reading from a console writes the prompt and the typed characters to `stdout`, which cannot share the stream with the envelopes, so the run is refused with `interactiveInputNotSupported` and exit code `1` before any command starts, leaving nothing half-done. + In non-interactive mode, do not use `open` as part of the agent workflow. It does not establish reusable state for later process launches. For stateful non-interactive commands, use `--unlock-password-stdin=true` instead. For all agent examples in this file, `` means the account address returned by `list-accounts` in `data[].address`. Prefer that address string for `-a` instead of the numeric `id`, unless a command explicitly documents another identifier format. @@ -543,6 +549,7 @@ Notes: - `twoFactorCode` is required only if the Proton flow asks for it - `humanVerificationToken` is required only if the Proton flow asks for it; see `Proton human verification (captcha)` - if the mailbox password is the same as the account password, repeat the same value in `mailboxPassword` +- structured input is fixed for the whole run, so a value Proton rejects is never resent: the command stops with `authorizationCanceled` instead of replaying the same `twoFactorCode`, `mailboxPassword`, or `humanVerificationToken` - invalid structured input returns one of these machine-readable errors: - `structuredStandardInputInvalidJson` - `structuredStandardInputMissingProperty` @@ -553,10 +560,10 @@ Proton can require human verification during `add-account -t proton`. The CLI ca JSON mode emits: ```json -{"type":"status","code":"humanVerificationRequired","data":{"verificationUri":"https://account.proton.me/api/core/v4/captcha?Token="}} +{"type":"status","code":"humanVerificationRequired","data":{"verificationUri":"https://mail-api.proton.me/core/v4/captcha?Token=","helpUri":"
"}} ``` -Text mode prints the same address together with a link to this section. +Text mode prints the same two addresses. `data.helpUri` always points at this section, so a run can be diagnosed without the command line at hand. How to obtain the token: 1. Open `verificationUri` in a web browser. @@ -577,6 +584,8 @@ Notes: - the token is single-use; a new challenge requires a new token - the token survives a process restart until it is consumed, so it can be prepared in one run and used in the next - the captcha page cannot be embedded in a local page; open the address directly +- an empty line is never sent to Proton, for `humanVerificationToken`, `twoFactorCode` and `mailboxPassword` alike, so watch for stray newlines in a `stdin` payload; the first two are trimmed before that check, while a `mailboxPassword` of only spaces counts as a real password and is sent +- a missing or blank token in `--input-json-stdin` gives `structuredStandardInputMissingProperty`; a rejected one gives `unsuccessfulAttempt`, a replacement `humanVerificationRequired`, then `authorizationCanceled`; a line-based `stdin` that ends first gives `standardInputEnded`. Each exits `1`, and a redirected `stdin` is never asked twice ## Reference: recommended agent workflow @@ -626,6 +635,10 @@ When `--output=json` is enabled, handle responses by `type` first: | --- | --- | --- | | `invalidPassword` | vault password was rejected | stop; do not retry automatically with the same password | | `humanVerificationRequired` | Proton asked for a captcha and `data.verificationUri` holds the challenge address | solve the captcha, then retry with `humanVerificationToken` in the structured input; see `Proton human verification (captcha)` | +| `inputRequired` | the run is waiting for `data.input` on standard input | supply that value; not an outcome -- another envelope follows | +| `interactiveInputNotSupported` | `--output=json` was used without `--non-interactive=true` on a command that reads a value | add `--non-interactive=true` and supply the values on `stdin` | +| `authorizationCanceled` | a value was rejected and could not be replaced, so the login was abandoned | supply a corrected value and run again; do not replay the rejected one | +| `standardInputEnded` | `stdin` ran out before a required value was read | extend the `stdin` payload with the missing value | | `structuredStandardInputInvalidJson` | structured payload is not valid JSON | fix serialization and retry once with corrected JSON | | `structuredStandardInputMissingProperty` | required JSON property is missing or empty | provide the missing property and retry once | | `unhandledException` | command failed and returned exception details | inspect `data.exceptionType` and command context; do not retry blindly | diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandIntegrationTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandIntegrationTests.cs index 2de5927a..2b5078d5 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandIntegrationTests.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandIntegrationTests.cs @@ -78,7 +78,7 @@ public async Task ProcessWhenNoStartupCommandIsProvidedInNonInteractiveModeOutpu Assert.Multiple(() => { - AssertProcessSucceeded(result); + AssertProcessFailed(result); Assert.That(result.StandardOutput, Does.Contain(TestConstants.InteractiveMenu)); Assert.That(result.StandardOutput, Does.Contain(TestConstants.NonInteractiveOption)); Assert.That(result.StandardOutput, Does.Not.Contain(TestConstants.InteractivePrompt)); @@ -205,7 +205,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit Assert.Multiple(() => { - AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode); + AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode, expectSuccess: false); Assert.That(warningData.GetProperty(TestConstants.JsonCommandNameProperty).GetString(), Is.EqualTo(TestConstants.ListAccountsCommand)); }); } @@ -224,7 +224,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit Assert.Multiple(() => { - AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode); + AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode, expectSuccess: false); Assert.That(warningData.GetProperty(TestConstants.JsonCommandNameProperty).GetString(), Is.EqualTo(TestConstants.ListAccountsCommand)); }); } @@ -243,7 +243,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit unlockPasswordFromStandardInput: true, nonInteractive: true).ConfigureAwait(false); - AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.InvalidPasswordCode); + AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.InvalidPasswordCode, expectSuccess: false); } [Test] @@ -255,7 +255,7 @@ public async Task ProcessWhenOpenRunsInNonInteractiveTextModeOnUninitializedAppl Assert.Multiple(() => { - AssertProcessSucceeded(result); + AssertProcessFailed(result); AssertContainsAllTerms(result.StandardOutput, TestConstants.InitializedCode); Assert.That(result.StandardOutput, Does.Not.Contain(TestConstants.InteractivePrompt)); }); @@ -274,7 +274,7 @@ public async Task ProcessWhenOpenRunsInJsonModeAfterResetOutputsStructuredUninit using JsonCommandResult openResult = await harness.RunJsonCommandAsync(TestConstants.OpenCommand, nonInteractive: true).ConfigureAwait(false); - AssertJsonTypeAndCode(openResult, TestConstants.JsonWarningType, TestConstants.UninitializedCode); + AssertJsonTypeAndCode(openResult, TestConstants.JsonWarningType, TestConstants.UninitializedCode, expectSuccess: false); } [Test] @@ -320,7 +320,7 @@ public async Task ProcessWhenResetRunsInNonInteractiveModeWithoutAssumeYesShowsD Assert.Multiple(() => { - AssertProcessSucceeded(resetResult); + AssertProcessFailed(resetResult); Assert.That(resetResult.StandardOutput, Does.Contain(TestConstants.AssumeYesOption)); Assert.That(resetResult.StandardOutput, Does.Not.Contain("An error has occurred:")); Assert.That(resetResult.StandardOutput, Does.Not.Contain("System.InvalidOperationException")); @@ -339,7 +339,7 @@ public async Task ProcessWhenResetRunsInNonInteractiveJsonModeWithoutAssumeYesOu Assert.Multiple(() => { - AssertJsonTypeAndCode(resetResult, TestConstants.JsonWarningType, TestConstants.CommandRequiresAssumeYesInNonInteractiveModeCode); + AssertJsonTypeAndCode(resetResult, TestConstants.JsonWarningType, TestConstants.CommandRequiresAssumeYesInNonInteractiveModeCode, expectSuccess: false); Assert.That(resetResult.RootElement.GetProperty(TestConstants.JsonMessageProperty).GetString(), Does.Contain(TestConstants.AssumeYesOption)); }); } @@ -390,7 +390,7 @@ public async Task ProcessWhenOpenRunsInJsonModeOnUninitializedApplicationOutputs Assert.Multiple(() => { - AssertJsonTypeAndCode(result, TestConstants.JsonWarningType, TestConstants.UninitializedCode); + AssertJsonTypeAndCode(result, TestConstants.JsonWarningType, TestConstants.UninitializedCode, expectSuccess: false); Assert.That(GetJsonMessage(result), Does.Contain("hasn't been initialized yet")); }); } @@ -425,7 +425,7 @@ public async Task ProcessWhenLockedStartupCommandIsLaunchedInNonInteractiveModeW Assert.Multiple(() => { - AssertProcessSucceeded(listAccountsResult); + AssertProcessFailed(listAccountsResult); Assert.That(listAccountsResult.StandardOutput, Does.Contain(TestConstants.UnlockPasswordStdinOption)); Assert.That(listAccountsResult.StandardOutput, Does.Not.Contain("There are no accounts yet.")); }); @@ -469,7 +469,7 @@ public async Task ProcessWhenUnlockPasswordFromStandardInputIsEnabledInNonIntera } [Test] - public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeOmitsAccountAddressPrompt() + public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeWithoutInputFailsWithoutPrompting() { using ApplicationCommandTestHarness harness = ApplicationCommandTestHarness.Create("eppie-cli-proton-non-interactive-"); @@ -485,7 +485,8 @@ public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeOmitsAccoun Assert.Multiple(() => { - AssertProcessSucceeded(addAccountResult); + AssertProcessFailed(addAccountResult); + AssertContainsAllTerms(addAccountResult.StandardOutput, TestConstants.StandardInputEndedCode); AssertOutputOmitsSetupPrompts(addAccountResult.StandardOutput); }); } @@ -598,7 +599,7 @@ public async Task ProcessWhenUnhandledExceptionRunsInNonInteractiveJsonModeKeeps Assert.Multiple(() => { - AssertProcessSucceeded(sendResult); + AssertProcessFailed(sendResult.ProcessResult); Assert.That(sendResult.ProcessResult.StandardError, Is.Empty); Assert.That(sendResult.RootElement.GetProperty(TestConstants.JsonTypeProperty).GetString(), Is.EqualTo(TestConstants.JsonErrorType)); Assert.That(sendResult.RootElement.GetProperty(TestConstants.JsonCodeProperty).GetString(), Is.EqualTo(TestConstants.UnhandledExceptionCode)); diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandTestHarness.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandTestHarness.cs index 092629d1..3d7ad73d 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandTestHarness.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationCommandTestHarness.cs @@ -247,9 +247,16 @@ internal static JsonCommandResult Create(ProcessResult processResult) { ArgumentNullException.ThrowIfNull(processResult); + string[] envelopes = processResult.StandardOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (envelopes.Length == 0) + { + Assert.Fail($"The command produced no output.{Environment.NewLine}Exit code: {processResult.ExitCode}"); + } + try { - return new JsonCommandResult(processResult, JsonDocument.Parse(processResult.StandardOutput)); + return new JsonCommandResult(processResult, JsonDocument.Parse(envelopes[^1])); } catch (JsonException ex) { diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationMenuLoopTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationMenuLoopTests.cs index dac029e0..9f0194c1 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationMenuLoopTests.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationMenuLoopTests.cs @@ -16,6 +16,7 @@ // // // ---------------------------------------------------------------------------- // +using Eppie.CLI.Exceptions; using Eppie.CLI.Menu; using Eppie.CLI.Services; using Eppie.CLI.Tests.TestDoubles; @@ -147,13 +148,83 @@ public async Task ExecuteAsyncWhenStartupCommandIsNotRunPassesCancelableNonCance }); } + [Test] + public async Task ExecuteAsyncWhenStartupCommandThrowsUnexpectedExceptionWritesUnhandledExceptionAndStopsApplication() + { + FakeApplicationMenu applicationMenu = new(); + using FakeHostApplicationLifetime lifetime = new(); + InvalidOperationException failure = new("the core failed outside a controlled outcome"); + FakeStartupCommandRunner startupCommandRunner = new() { TryRunException = failure }; + FakeApplicationOutputWriter outputWriter = new(); + using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu); + + await loop.RunAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(startupCommandRunner.TryRunCallCount, Is.EqualTo(1)); + Assert.That(applicationMenu.LoopCallCount, Is.Zero); + Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1)); + Assert.That(outputWriter.LastOutput, Is.TypeOf()); + Assert.That(((UnhandledExceptionOutput)outputWriter.LastOutput!).Exception, Is.SameAs(failure)); + }); + } + + [Test] + public async Task ExecuteAsyncWhenTheRunIsCanceledStopsWithoutReportingAnError() + { + FakeApplicationMenu applicationMenu = new(); + using FakeHostApplicationLifetime lifetime = new(); + using CancellationTokenSource cancellationTokenSource = new(); + FakeStartupCommandRunner startupCommandRunner = new() + { + OnTryRun = cancellationTokenSource.Cancel, + TryRunException = new OperationCanceledException() + }; + FakeApplicationOutputWriter outputWriter = new(); + using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu); + + await loop.RunAsync(cancellationTokenSource.Token).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(outputWriter.Outputs, Is.Empty); + Assert.That(applicationMenu.LoopCallCount, Is.Zero); + Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1)); + }); + } + + [Test] + public async Task ExecuteAsyncWhenAPromptIsCanceledByTheUserStopsWithoutReportingAnError() + { + FakeApplicationMenu applicationMenu = new(); + using FakeHostApplicationLifetime lifetime = new(); + FakeStartupCommandRunner startupCommandRunner = new() { TryRunException = new InputCanceledByUserException() }; + FakeApplicationOutputWriter outputWriter = new(); + using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu); + + await loop.RunAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(outputWriter.Outputs, Is.Empty); + Assert.That(applicationMenu.LoopCallCount, Is.Zero); + Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1)); + }); + } + private sealed class TestApplicationMenuLoop( IHostApplicationLifetime lifetime, IOptions launchOptions, IStartupCommandRunner startupCommandRunner, IApplicationOutputWriter outputWriter, IApplicationMenu applicationMenu) - : ApplicationMenuLoop(NullLogger.Instance, lifetime, launchOptions, startupCommandRunner, outputWriter, applicationMenu) + : ApplicationMenuLoop(NullLogger.Instance, + lifetime, + launchOptions, + startupCommandRunner, + new ApplicationFailureHandler(NullLogger.Instance, launchOptions, outputWriter), + applicationMenu) { internal Task RunAsync(CancellationToken cancellationToken) { @@ -165,11 +236,18 @@ private sealed class FakeStartupCommandRunner : IStartupCommandRunner { internal int TryRunCallCount { get; private set; } internal bool TryRunResult { get; init; } + internal Exception? TryRunException { get; init; } + internal Action? OnTryRun { get; init; } public Task TryRunAsync(CancellationToken cancellationToken) { TryRunCallCount++; - return Task.FromResult(TryRunResult); + + OnTryRun?.Invoke(); + + return TryRunException is null + ? Task.FromResult(TryRunResult) + : Task.FromException(TryRunException); } } diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationPromptTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationPromptTests.cs new file mode 100644 index 00000000..c067ad64 --- /dev/null +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationPromptTests.cs @@ -0,0 +1,160 @@ +// ---------------------------------------------------------------------------- // +// // +// Copyright 2026 Eppie (https://eppie.io) // +// // +// 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 Eppie.CLI.Exceptions; +using Eppie.CLI.Options; +using Eppie.CLI.Services; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +using NUnit.Framework; + +namespace Eppie.CLI.Tests.Services +{ + [TestFixture] + public class ApplicationPromptTests + { + private const string PromptMarker = "Enter "; + + private ServiceProvider _serviceProvider = null!; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _serviceProvider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddLocalization() + .BuildServiceProvider(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _serviceProvider.Dispose(); + } + + [Test] + public void NonInteractiveTwoFactorCodeRequestReplacesThePromptWithAnAnnouncement() + { + (string output, string value) = ReadWithRedirectedInput("123456", application => application.AskTwoFactorCode(firstAttempt: true), outputJson: true); + + Assert.Multiple(() => + { + Assert.That(value, Is.EqualTo("123456")); + + Assert.That(output, Does.Not.Contain(PromptMarker)); + Assert.That(output, Does.Contain("twoFactorCode")); + }); + } + + [Test] + public void NonInteractiveHumanVerificationTokenRequestReplacesThePromptWithAnAnnouncement() + { + (string output, string value) = ReadWithRedirectedInput("challenge:solution", application => application.AskHumanVerificationToken(), outputJson: true); + + Assert.Multiple(() => + { + Assert.That(value, Is.EqualTo("challenge:solution")); + Assert.That(output, Does.Not.Contain(PromptMarker)); + Assert.That(output, Does.Contain("humanVerificationToken")); + }); + } + + [Test] + public void InteractiveTwoFactorCodeRequestStillShowsThePrompt() + { + (string output, _) = ReadWithRedirectedInput("123456", + application => application.AskTwoFactorCode(firstAttempt: true), + nonInteractive: false); + + Assert.That(output, Does.Contain("two-factor")); + } + + [TestCase("AskTwoFactorCode", TestName = "InteractiveJsonRunRefusesToReadTheTwoFactorCode")] + [TestCase("AskHumanVerificationToken", TestName = "InteractiveJsonRunRefusesToReadTheVerificationToken")] + public void InteractiveJsonRunRefusesToReadAValue(string prompt) + { + ApplicationCommandException? exception = Assert.Throws( + () => ReadWithRedirectedInput("123456", + application => prompt == "AskTwoFactorCode" + ? application.AskTwoFactorCode(firstAttempt: true) + : application.AskHumanVerificationToken(), + nonInteractive: false, + outputJson: true)); + + Assert.That(exception!.Output, Is.InstanceOf()); + } + + private (string Output, string Value) ReadWithRedirectedInput(string input, + Func read, + bool nonInteractive = true, + bool outputJson = false) + { + ArgumentNullException.ThrowIfNull(read); + + TextReader originalIn = Console.In; + try + { + using StringReader reader = new(input + Environment.NewLine); + Console.SetIn(reader); + + string value = string.Empty; + string output = TestConsole.CaptureOutput(() => value = read(CreateApplication(nonInteractive, outputJson))); + + return (output, value); + } + finally + { + Console.SetIn(originalIn); + } + } + + private Application CreateApplication(bool nonInteractive, bool outputJson = false) + { + IStringLocalizer localizer = _serviceProvider.GetRequiredService>(); + ResourceLoader resourceLoader = new(localizer); + + IApplicationOutputWriter writer = outputJson + ? new JsonApplicationOutputWriter(resourceLoader) + : new TextApplicationOutputWriter(resourceLoader); + + return new Application(NullLogger.Instance, + new StubHostApplicationLifetime(), + TestApplicationFactory.CreateLaunchOptionsOptions(nonInteractive: nonInteractive), + writer, + Microsoft.Extensions.Options.Options.Create(new MailOptions()), + resourceLoader); + } + + private sealed class StubHostApplicationLifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => CancellationToken.None; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + } + } + } +} diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationSuccessOutputWriterTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationSuccessOutputWriterTests.cs index 32b152af..5e6bac24 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationSuccessOutputWriterTests.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationSuccessOutputWriterTests.cs @@ -163,6 +163,21 @@ public void JsonWriterWhenFolderSyncedOutputsStructuredStatus() }); } + [Test] + public void JsonWriterWhenProtonHumanVerificationRequiredOutputsStructuredStatus() + { + using JsonDocument document = CaptureJsonDocument(writer => writer.Write(new ProtonHumanVerificationRequiredOutput(new Uri(TestConstants.ProtonCaptchaUri)))); + JsonElement data = GetData(document); + + Assert.Multiple(() => + { + AssertJsonTypeAndCode(document, TestConstants.JsonStatusType, TestConstants.HumanVerificationRequiredCode); + Assert.That(data.GetProperty(TestConstants.JsonVerificationUriProperty).GetString(), Is.EqualTo(TestConstants.ProtonCaptchaUri)); + Assert.That(data.GetProperty(TestConstants.JsonHelpUriProperty).GetString(), + Is.EqualTo(ProtonHumanVerificationRequiredOutput.HelpUri.ToString())); + }); + } + [Test] public void JsonWriterWhenMessageDeletedOutputsStructuredStatus() { @@ -251,14 +266,19 @@ public void JsonWriterWhenApplicationRestoredOutputsStructuredStatus() } [Test] - public void JsonWriterWhenAuthorizationCanceledOutputsStructuredStatus() + public void JsonWriterWhenAuthorizationCanceledOutputsStructuredError() { string json = CaptureConsoleOutput(() => CreateJsonWriter().Write(new AuthorizationCanceledOutput())); using JsonDocument document = JsonDocument.Parse(json); - Assert.That(document.RootElement.GetProperty(TestConstants.JsonTypeProperty).GetString(), Is.EqualTo(TestConstants.JsonStatusType)); - Assert.That(document.RootElement.GetProperty(TestConstants.JsonCodeProperty).GetString(), Is.EqualTo(TestConstants.AuthorizationCanceledCode)); + Assert.Multiple(() => + { + Assert.That(document.RootElement.GetProperty(TestConstants.JsonTypeProperty).GetString(), Is.EqualTo(TestConstants.JsonErrorType)); + Assert.That(document.RootElement.GetProperty(TestConstants.JsonCodeProperty).GetString(), Is.EqualTo(TestConstants.AuthorizationCanceledCode)); + Assert.That(document.RootElement.GetProperty(TestConstants.JsonMessageProperty).GetString(), + Is.EqualTo(CreateResourceLoader().Strings.AuthorizationCanceled)); + }); } [Test] @@ -520,6 +540,18 @@ public void TextWriterWhenFolderSyncedOutputsConfirmationText() Assert.That(output, Does.Contain($"Folder '{TestConstants.Inbox}' for account {TestConstants.AccountAddress} synchronized.")); } + [Test] + public void TextWriterWhenProtonHumanVerificationRequiredOutputsVerificationUri() + { + string output = CaptureConsoleOutput(() => CreateTextWriter().Write(new ProtonHumanVerificationRequiredOutput(new Uri(TestConstants.ProtonCaptchaUri)))); + + Assert.Multiple(() => + { + Assert.That(output, Does.Contain(TestConstants.ProtonCaptchaUri)); + Assert.That(output, Does.Contain(ProtonHumanVerificationRequiredOutput.HelpUri.ToString())); + }); + } + [Test] public void TextWriterWhenMessageDeletedOutputsConfirmationText() { diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationUnlockerTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationUnlockerTests.cs index ad9484cf..af0abc97 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationUnlockerTests.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ApplicationUnlockerTests.cs @@ -16,6 +16,7 @@ // // // ---------------------------------------------------------------------------- // +using Eppie.CLI.Exceptions; using Eppie.CLI.Services; using Eppie.CLI.Tests.TestDoubles; @@ -31,112 +32,101 @@ namespace Eppie.CLI.Tests.Services public class ApplicationUnlockerTests { [Test] - public async Task UnlockAsyncWhenApplicationIsUninitializedReturnsFalseAndWritesUninitializedWarning() + public void UnlockAsyncWhenApplicationIsUninitializedReportsUninitializedFailure() { - FakeApplicationOutputWriter outputWriter = new(); TuviMailInvocationState tuviMailState = new() { IsFirstApplicationStartResult = true }; - ApplicationUnlocker unlocker = CreateUnlocker(outputWriter, tuviMailState); + ApplicationUnlocker unlocker = CreateUnlocker(tuviMailState); - bool result = await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true).ConfigureAwait(false); + ApplicationCommandException? exception = Assert.ThrowsAsync( + () => unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true)); Assert.Multiple(() => { - Assert.That(result, Is.False); Assert.That(tuviMailState.InitializeApplicationCallCount, Is.Zero); - Assert.That(outputWriter.LastOutput, Is.TypeOf()); + Assert.That(exception!.Output, Is.TypeOf()); }); } [Test] public async Task UnlockAsyncWhenReadPasswordFromStandardInputIsTrueReadsProvidedPasswordAndInitializesApplication() { - FakeApplicationOutputWriter outputWriter = new(); TuviMailInvocationState tuviMailState = new(); FakeApplicationPasswordReader passwordReader = new() { StandardInputPassword = TestConstants.VaultPassword }; - ApplicationUnlocker unlocker = CreateUnlocker(outputWriter, tuviMailState, passwordReader); + ApplicationUnlocker unlocker = CreateUnlocker(tuviMailState, passwordReader); - bool result = await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true).ConfigureAwait(false); + await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true).ConfigureAwait(false); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(tuviMailState.InitializeApplicationCallCount, Is.EqualTo(1)); Assert.That(tuviMailState.LastPassword, Is.EqualTo(TestConstants.VaultPassword)); Assert.That(passwordReader.ReadPasswordFromStandardInputCallCount, Is.EqualTo(1)); Assert.That(passwordReader.AskPasswordCallCount, Is.Zero); - Assert.That(outputWriter.LastOutput, Is.Null); }); } [Test] public async Task UnlockAsyncWhenReadPasswordFromStandardInputIsFalseUsesInteractivePasswordReader() { - FakeApplicationOutputWriter outputWriter = new(); TuviMailInvocationState tuviMailState = new(); FakeApplicationPasswordReader passwordReader = new() { Password = "interactive-password" }; - ApplicationUnlocker unlocker = CreateUnlocker(outputWriter, tuviMailState, passwordReader); + ApplicationUnlocker unlocker = CreateUnlocker(tuviMailState, passwordReader); - bool result = await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: false).ConfigureAwait(false); + await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: false).ConfigureAwait(false); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(tuviMailState.InitializeApplicationCallCount, Is.EqualTo(1)); Assert.That(tuviMailState.LastPassword, Is.EqualTo("interactive-password")); Assert.That(passwordReader.AskPasswordCallCount, Is.EqualTo(1)); Assert.That(passwordReader.ReadPasswordFromStandardInputCallCount, Is.Zero); - Assert.That(outputWriter.LastOutput, Is.Null); }); } [Test] - public async Task UnlockAsyncWhenPasswordIsInvalidReturnsFalseAndWritesInvalidPasswordWarning() + public void UnlockAsyncWhenPasswordIsInvalidReportsInvalidPasswordFailure() { - FakeApplicationOutputWriter outputWriter = new(); TuviMailInvocationState tuviMailState = new() { InitializeApplicationResult = false }; FakeApplicationPasswordReader passwordReader = new() { StandardInputPassword = TestConstants.WrongPassword }; - ApplicationUnlocker unlocker = CreateUnlocker(outputWriter, tuviMailState, passwordReader); + ApplicationUnlocker unlocker = CreateUnlocker(tuviMailState, passwordReader); - bool result = await unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true).ConfigureAwait(false); + ApplicationCommandException? exception = Assert.ThrowsAsync( + () => unlocker.UnlockAsync(CancellationToken.None, readPasswordFromStandardInput: true)); Assert.Multiple(() => { - Assert.That(result, Is.False); + Assert.That(exception!.Output, Is.TypeOf()); Assert.That(tuviMailState.InitializeApplicationCallCount, Is.EqualTo(1)); Assert.That(passwordReader.ReadPasswordFromStandardInputCallCount, Is.EqualTo(1)); - Assert.That(outputWriter.LastOutput, Is.TypeOf()); }); } [Test] public async Task UnlockAsyncPassesCancellationTokenToCoreCalls() { - FakeApplicationOutputWriter outputWriter = new(); TuviMailInvocationState tuviMailState = new(); FakeApplicationPasswordReader passwordReader = new() { StandardInputPassword = TestConstants.VaultPassword }; - ApplicationUnlocker unlocker = CreateUnlocker(outputWriter, tuviMailState, passwordReader); + ApplicationUnlocker unlocker = CreateUnlocker(tuviMailState, passwordReader); using CancellationTokenSource cancellationTokenSource = new(); - bool result = await unlocker.UnlockAsync(cancellationTokenSource.Token, readPasswordFromStandardInput: true).ConfigureAwait(false); + await unlocker.UnlockAsync(cancellationTokenSource.Token, readPasswordFromStandardInput: true).ConfigureAwait(false); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(tuviMailState.LastIsFirstApplicationStartCancellationToken, Is.EqualTo(cancellationTokenSource.Token)); Assert.That(tuviMailState.LastInitializeApplicationCancellationToken, Is.EqualTo(cancellationTokenSource.Token)); }); } [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "The fake core instance is assigned to the test core provider and lives for the lifetime of the unlocker under test.")] - private static ApplicationUnlocker CreateUnlocker(FakeApplicationOutputWriter outputWriter, TuviMailInvocationState tuviMailState, FakeApplicationPasswordReader? passwordReader = null) + private static ApplicationUnlocker CreateUnlocker(TuviMailInvocationState tuviMailState, FakeApplicationPasswordReader? passwordReader = null) { - ArgumentNullException.ThrowIfNull(outputWriter); ArgumentNullException.ThrowIfNull(tuviMailState); FakeTuviMailCoreProvider coreProvider = new(new FakeTuviMail(tuviMailState)); passwordReader ??= new FakeApplicationPasswordReader(); - return new ApplicationUnlocker(NullLogger.Instance, passwordReader, outputWriter, coreProvider); + return new ApplicationUnlocker(NullLogger.Instance, passwordReader, coreProvider); } private sealed class FakeApplicationPasswordReader : IApplicationPasswordReader diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonAccountInputResolverTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonAccountInputResolverTests.cs new file mode 100644 index 00000000..ad286242 --- /dev/null +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonAccountInputResolverTests.cs @@ -0,0 +1,134 @@ +// ---------------------------------------------------------------------------- // +// // +// Copyright 2026 Eppie (https://eppie.io) // +// // +// 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 Eppie.CLI.Exceptions; +using Eppie.CLI.Options; +using Eppie.CLI.Services; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +using NUnit.Framework; + +namespace Eppie.CLI.Tests.Services +{ + [TestFixture] + public class ProtonAccountInputResolverTests + { + private const string HumanVerificationToken = "captcha-token:solution"; + + private ServiceProvider _serviceProvider = null!; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _serviceProvider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddLocalization() + .BuildServiceProvider(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _serviceProvider.Dispose(); + } + + [Test] + public async Task StructuredStandardInputWhenHumanVerificationTokenIsPresentReturnsIt() + { + IProtonAccountInput input = await ResolveStructuredInputAsync( + $$"""{"email":"{{TestConstants.UserAddress}}","accountPassword":"secret","humanVerificationToken":"{{HumanVerificationToken}}"}""").ConfigureAwait(false); + + Assert.That(input.GetHumanVerificationToken(), Is.EqualTo(HumanVerificationToken)); + } + + [Test] + public async Task StructuredStandardInputWhenHumanVerificationTokenIsMissingReportsMissingProperty() + { + IProtonAccountInput input = await ResolveStructuredInputAsync( + $$"""{"email":"{{TestConstants.UserAddress}}","accountPassword":"secret"}""").ConfigureAwait(false); + + ApplicationCommandException? exception = Assert.Throws(() => input.GetHumanVerificationToken()); + + Assert.That(exception!.Output, Is.InstanceOf()); + Assert.That(((StructuredStandardInputMissingPropertyErrorOutput)exception.Output!).PropertyName, + Is.EqualTo(TestConstants.HumanVerificationTokenPropertyName)); + } + + [Test] + public async Task LineBasedStandardInputCannotBeRetriedInNonInteractiveMode() + { + IProtonAccountInput input = await ResolveWithRedirectedInputAsync($"{TestConstants.UserAddress}{Environment.NewLine}secret{Environment.NewLine}", + inputJsonFromStandardInput: false, + nonInteractive: true).ConfigureAwait(false); + + Assert.That(input.SupportsRetry, Is.False); + } + + private Task ResolveStructuredInputAsync(string json) + { + return ResolveWithRedirectedInputAsync(json, inputJsonFromStandardInput: true, nonInteractive: true); + } + + private async Task ResolveWithRedirectedInputAsync(string input, bool inputJsonFromStandardInput, bool nonInteractive) + { + TextReader originalIn = Console.In; + try + { + using StringReader reader = new(input); + Console.SetIn(reader); + + ProtonAccountInputResolver resolver = new(CreateApplication(nonInteractive)); + return await resolver.ResolveAsync(inputJsonFromStandardInput).ConfigureAwait(false); + } + finally + { + Console.SetIn(originalIn); + } + } + + private Application CreateApplication(bool nonInteractive) + { + IStringLocalizer localizer = _serviceProvider.GetRequiredService>(); + ResourceLoader resourceLoader = new(localizer); + + return new Application(NullLogger.Instance, + new StubHostApplicationLifetime(), + TestApplicationFactory.CreateLaunchOptionsOptions(nonInteractive: nonInteractive), + new TextApplicationOutputWriter(resourceLoader), + Microsoft.Extensions.Options.Options.Create(new MailOptions()), + resourceLoader); + } + + private sealed class StubHostApplicationLifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => CancellationToken.None; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + } + } + } +} diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonLoginRetryPolicyTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonLoginRetryPolicyTests.cs new file mode 100644 index 00000000..dc68f3a2 --- /dev/null +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/ProtonLoginRetryPolicyTests.cs @@ -0,0 +1,428 @@ +// ---------------------------------------------------------------------------- // +// // +// Copyright 2026 Eppie (https://eppie.io) // +// // +// 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 System.Collections.ObjectModel; + +using Eppie.CLI.Exceptions; +using Eppie.CLI.Menu; +using Eppie.CLI.Options; +using Eppie.CLI.Services; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +using NUnit.Framework; + +using Tuvi.Core; +using Tuvi.Core.Entities; +using Tuvi.Proton; + +namespace Eppie.CLI.Tests.Services +{ + [TestFixture] + public class ProtonLoginRetryPolicyTests + { + private const string ValidToken = "challenge:solution"; + + private ServiceProvider _serviceProvider = null!; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _serviceProvider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddLocalization() + .BuildServiceProvider(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _serviceProvider.Dispose(); + } + + [TestCase(ProtonLoginStage.HumanVerification)] + [TestCase(ProtonLoginStage.TwoFactorCode)] + [TestCase(ProtonLoginStage.MailboxPassword)] + public void WhenStructuredValueIsRejectedTheLoginIsNotRetriedForever(ProtonLoginStage stage) + { + RecordingOutputWriter writer = new(); + RetryingLoginHelper loginHelper = new(stage); + + ApplicationCommandException? exception = Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput(ValidToken))); + + Assert.Multiple(() => + { + Assert.That(loginHelper.ProviderCallCount, Is.EqualTo(2), "a value that cannot change must not be replayed"); + Assert.That(loginHelper.LimitReached, Is.False); + Assert.That(loginHelper.Cancelled, Is.True); + Assert.That(writer.Written, Has.Some.InstanceOf()); + + Assert.That(writer.Written.OfType().Count(), + Is.EqualTo(stage == ProtonLoginStage.HumanVerification ? 2 : 0)); + + Assert.That(exception!.Output, Is.InstanceOf()); + }); + } + + [Test] + public void WhenAnEmptyTokenIsEnteredVerificationIsDeclinedWithoutContactingProton() + { + RecordingOutputWriter writer = new(); + RetryingLoginHelper loginHelper = new(ProtonLoginStage.HumanVerification); + + ApplicationCommandException? exception = Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput(" ", supportsRetry: true))); + + Assert.Multiple(() => + { + Assert.That(loginHelper.ProviderCallCount, Is.EqualTo(1)); + Assert.That(loginHelper.Cancelled, Is.True); + Assert.That(exception!.Output, Is.InstanceOf()); + }); + } + + [TestCase(ProtonLoginStage.HumanVerification)] + [TestCase(ProtonLoginStage.TwoFactorCode)] + [TestCase(ProtonLoginStage.MailboxPassword)] + public void WhenAValueIsBlankItIsDeclinedWithoutContactingProton(ProtonLoginStage stage) + { + RecordingOutputWriter writer = new(); + RetryingLoginHelper loginHelper = new(stage); + + ApplicationCommandException? exception = Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput(string.Empty, supportsRetry: true))); + + Assert.Multiple(() => + { + Assert.That(loginHelper.ProviderCallCount, Is.EqualTo(1)); + Assert.That(loginHelper.Cancelled, Is.True); + Assert.That(exception!.Output, Is.InstanceOf()); + }); + } + + [Test] + public void WhenTheLoginIsCancelledWithoutAnOperatorDecliningItKeepsItsOwnIdentity() + { + RecordingOutputWriter writer = new(); + TimingOutLoginHelper loginHelper = new(); + + Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput(ValidToken))); + + Assert.That(writer.Written, Has.None.InstanceOf()); + } + + [Test] + public void WhenStandardInputRunsOutTheLoginLeavesTheReasonIntact() + { + RecordingOutputWriter writer = new(); + RetryingLoginHelper loginHelper = new(ProtonLoginStage.HumanVerification); + + Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new ExhaustedStandardInput())); + + Assert.That(writer.Written, Has.None.InstanceOf()); + } + + [Test] + public void SurroundingWhitespaceIsStrippedFromTheVerificationToken() + { + RecordingOutputWriter writer = new(); + CapturingLoginHelper loginHelper = new(); + + Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput($" {ValidToken}\t"))); + + Assert.That(loginHelper.CapturedToken, Is.EqualTo(ValidToken)); + } + + [TestCase(ProtonLoginStage.HumanVerification)] + [TestCase(ProtonLoginStage.TwoFactorCode)] + [TestCase(ProtonLoginStage.MailboxPassword)] + public void WhenInputCanChangeTheValueTheLoginKeepsAskingAgain(ProtonLoginStage stage) + { + RecordingOutputWriter writer = new(); + RetryingLoginHelper loginHelper = new(stage, maxCalls: 3); + + Assert.ThrowsAsync( + () => RunAddProtonAccountAsync(writer, loginHelper, new StubProtonAccountInput(ValidToken, supportsRetry: true))); + + Assert.Multiple(() => + { + Assert.That(loginHelper.ProviderCallCount, Is.EqualTo(3)); + Assert.That(loginHelper.LimitReached, Is.True); + Assert.That(loginHelper.Cancelled, Is.False); + }); + } + + private Task RunAddProtonAccountAsync(RecordingOutputWriter writer, IProtonLoginHelper loginHelper, IProtonAccountInput input) + { + IStringLocalizer localizer = _serviceProvider.GetRequiredService>(); + ResourceLoader resourceLoader = new(localizer); + + Application application = new(NullLogger.Instance, + new StubHostApplicationLifetime(), + TestApplicationFactory.CreateLaunchOptionsOptions(nonInteractive: true), + writer, + Microsoft.Extensions.Options.Options.Create(new MailOptions()), + resourceLoader); + + Actions actions = new(NullLogger.Instance, + application, + TestApplicationFactory.CreateLaunchOptionsOptions(nonInteractive: true), + writer, + new StubFailureHandler(), + new StubOutputCoordinator(), + new StubEmailAccountInputResolver(), + new StubProtonAccountInputResolver(input), + loginHelper, + new AuthorizationProvider(NullLogger.Instance, _serviceProvider), + new StubCoreProvider()); + + return actions.AddAccountActionAsync(new MenuCommand.CommandAddAccountOptions.Options( + MenuCommand.CommandAddAccountOptions.AccountType.Proton, + InputJsonFromStandardInput: true)); + } + + public enum ProtonLoginStage + { + HumanVerification, + TwoFactorCode, + MailboxPassword, + } + + private sealed class RetryingLoginHelper(ProtonLoginStage stage, int maxCalls = 25) : IProtonLoginHelper + { + private static readonly Uri VerifierUri = new(TestConstants.ProtonCaptchaUri); + + internal int ProviderCallCount { get; private set; } + + internal bool Cancelled { get; private set; } + + internal bool LimitReached { get; private set; } + + public async Task LoginAsync(string userName, + string password, + TwoFactorCodeProvider twoFactorCodeProvider, + MailboxPasswordProvider mailboxPasswordProvider, + HumanVerifier humanVerifier, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(twoFactorCodeProvider); + ArgumentNullException.ThrowIfNull(mailboxPasswordProvider); + ArgumentNullException.ThrowIfNull(humanVerifier); + + Exception? previousAttemptException = null; + + while (ProviderCallCount < maxCalls) + { + ProviderCallCount++; + + bool completed = stage switch + { + ProtonLoginStage.TwoFactorCode => (await twoFactorCodeProvider(previousAttemptException, cancellationToken).ConfigureAwait(false)).completed, + ProtonLoginStage.MailboxPassword => (await mailboxPasswordProvider(previousAttemptException, cancellationToken).ConfigureAwait(false)).completed, + ProtonLoginStage.HumanVerification => (await humanVerifier(VerifierUri, previousAttemptException, cancellationToken).ConfigureAwait(false)).completed, + _ => throw new NotSupportedException($"Unknown stage '{stage}'."), + }; + + if (!completed) + { + Cancelled = true; + throw new OperationCanceledException(); + } + + previousAttemptException = new InvalidOperationException("Proton rejected the value"); + } + + LimitReached = true; + throw new NotSupportedException("The provider kept being asked up to the cap."); + } + } + + private sealed class TimingOutLoginHelper : IProtonLoginHelper + { + public Task LoginAsync(string userName, + string password, + TwoFactorCodeProvider twoFactorCodeProvider, + MailboxPasswordProvider mailboxPasswordProvider, + HumanVerifier humanVerifier, + CancellationToken cancellationToken) + { + throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout."); + } + } + + private sealed class CapturingLoginHelper : IProtonLoginHelper + { + private static readonly Uri VerifierUri = new(TestConstants.ProtonCaptchaUri); + + internal string? CapturedToken { get; private set; } + + public async Task LoginAsync(string userName, + string password, + TwoFactorCodeProvider twoFactorCodeProvider, + MailboxPasswordProvider mailboxPasswordProvider, + HumanVerifier humanVerifier, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(humanVerifier); + + (_, _, string token) = await humanVerifier(VerifierUri, null, cancellationToken).ConfigureAwait(false); + CapturedToken = token; + + throw new NotSupportedException("The token has been captured, the rest of the login is out of scope."); + } + } + + private sealed class ExhaustedStandardInput : IProtonAccountInput + { + public bool SupportsRetry => true; + + public string Email => TestConstants.UserAddress; + + public string AccountPassword => "secret"; + + public string GetTwoFactorCode(bool firstAttempt) + { + throw new ReadValueCanceledException(); + } + + public string GetMailboxPassword(bool firstAttempt) + { + throw new ReadValueCanceledException(); + } + + public string GetHumanVerificationToken() + { + throw new ReadValueCanceledException(); + } + } + + private sealed class StubProtonAccountInput(string value, bool supportsRetry = false) : IProtonAccountInput + { + public bool SupportsRetry => supportsRetry; + + public string Email => TestConstants.UserAddress; + + public string AccountPassword => "secret"; + + public string GetTwoFactorCode(bool firstAttempt) + { + return value; + } + + public string GetMailboxPassword(bool firstAttempt) + { + return value; + } + + public string GetHumanVerificationToken() + { + return value; + } + } + + private sealed class StubProtonAccountInputResolver(IProtonAccountInput input) : IProtonAccountInputResolver + { + public Task ResolveAsync(bool inputJsonFromStandardInput) + { + return Task.FromResult(input); + } + } + + private sealed class RecordingOutputWriter : IApplicationOutputWriter + { + internal Collection Written { get; } = []; + + public ApplicationOutputFormat Format => ApplicationOutputFormat.Text; + + public void Write(ApplicationOutput output) + { + Written.Add(output); + } + } + + private sealed class StubFailureHandler : IApplicationFailureHandler + { + public void HandleControlledCommandFailure(ApplicationCommandException exception) + { + throw exception ?? new ApplicationCommandException(); + } + + public void HandleUnhandledException(Exception exception) + { + throw exception ?? new InvalidOperationException(); + } + + public void ReportBackgroundException(Exception exception) + { + throw exception ?? new InvalidOperationException(); + } + } + + private sealed class StubOutputCoordinator : IApplicationOutputCoordinator + { + public void WriteContacts(ApplicationListingOptions options, IEnumerable contacts, Func askMore) + { + } + + public Task WriteMessagesAsync(string header, + ApplicationListingOptions options, + Func>> source, + Func askMore) + { + return Task.CompletedTask; + } + } + + private sealed class StubEmailAccountInputResolver : IEmailAccountInputResolver + { + public Task ResolveAsync() + { + throw new NotSupportedException(); + } + } + + private sealed class StubCoreProvider : ITuviMailCoreProvider + { + public ITuviMail TuviMailCore => throw new NotSupportedException("The login must fail before the account is stored."); + + public Task ResetAsync() + { + throw new NotSupportedException(); + } + } + + private sealed class StubHostApplicationLifetime : Microsoft.Extensions.Hosting.IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => CancellationToken.None; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + } + } + } +} diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/StartupCommandRunnerTests.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/StartupCommandRunnerTests.cs index 9d3c1e7a..7b05404e 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/StartupCommandRunnerTests.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/StartupCommandRunnerTests.cs @@ -16,6 +16,7 @@ // // // ---------------------------------------------------------------------------- // +using Eppie.CLI.Exceptions; using Eppie.CLI.Menu; using Eppie.CLI.Services; using Eppie.CLI.Tests.TestDoubles; @@ -37,7 +38,7 @@ public async Task TryRunAsyncWhenStartupCommandArgumentsAreMissingReturnsFalse() IOptions launchOptions = CreateLaunchOptions(); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -56,7 +57,7 @@ public async Task TryRunAsyncWhenOnlyLaunchOptionsAreConfiguredReturnsFalse() IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -75,7 +76,7 @@ public async Task TryRunAsyncWhenUnlockPasswordFromStandardInputIsProvidedWithEx IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -90,18 +91,17 @@ public async Task TryRunAsyncWhenUnlockPasswordFromStandardInputIsProvidedWithEx } [Test] - public async Task TryRunAsyncWhenLockedInteractiveStartupCommandUnlockFailsDoesNotExecuteCommand() + public void TryRunAsyncWhenLockedInteractiveStartupCommandUnlockFailsDoesNotExecuteCommand() { IOptions launchOptions = CreateLaunchOptions(); FakeApplicationUnlocker applicationUnlocker = new() { UnlockResult = false }; FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, applicationUnlocker, applicationMenu); - bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); + _ = Assert.ThrowsAsync(() => runner.TryRunAsync(CancellationToken.None)); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(applicationUnlocker.UnlockCallCount, Is.EqualTo(1)); Assert.That(applicationUnlocker.LastReadPasswordFromStandardInput, Is.False); Assert.That(applicationMenu.InvokeCommandCallCount, Is.Zero); @@ -114,7 +114,7 @@ public async Task TryRunAsyncWhenStartupCommandArgumentsAreConfiguredInvokesComm IOptions launchOptions = CreateLaunchOptions(); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, MenuCommand.Open.Name]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, MenuCommand.Open.Name]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -134,7 +134,7 @@ public async Task TryRunAsyncWhenStartupCommandIsPrefixedWithInlineLaunchOptionI IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -154,7 +154,7 @@ public async Task TryRunAsyncWhenStartupCommandIsPrefixedWithSeparateValueLaunch IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -174,7 +174,7 @@ public async Task TryRunAsyncWhenStartupCommandIsHelpDoesNotUnlockApplication() IOptions launchOptions = CreateLaunchOptions(unlockPasswordFromStandardInput: true); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, "-h"]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, "-h"]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -193,7 +193,7 @@ public async Task TryRunAsyncWhenStartupCommandIsOpenDoesNotUnlockApplication() IOptions launchOptions = CreateLaunchOptions(unlockPasswordFromStandardInput: true); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, MenuCommand.Open.Name]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, MenuCommand.Open.Name]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -212,7 +212,7 @@ public async Task TryRunAsyncWhenLockedInteractiveStartupCommandRunsUnlocksBefor IOptions launchOptions = CreateLaunchOptions(); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -232,7 +232,7 @@ public async Task TryRunAsyncWhenUnlockPasswordFromStandardInputIsEnabledInInter IOptions launchOptions = CreateLaunchOptions(unlockPasswordFromStandardInput: true); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([$"--{ApplicationLaunchOptions.UnlockPasswordFromStandardInputConfigurationKey}={TestConstants.True}", StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([$"--{ApplicationLaunchOptions.UnlockPasswordFromStandardInputConfigurationKey}={TestConstants.True}", StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -247,18 +247,17 @@ public async Task TryRunAsyncWhenUnlockPasswordFromStandardInputIsEnabledInInter } [Test] - public async Task TryRunAsyncWhenNonInteractiveUnlockFailsDoesNotExecuteCommand() + public void TryRunAsyncWhenNonInteractiveUnlockFailsDoesNotExecuteCommand() { IOptions launchOptions = CreateLaunchOptions(unlockPasswordFromStandardInput: true, nonInteractive: true); FakeApplicationUnlocker applicationUnlocker = new() { UnlockResult = false }; FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([$"--{ApplicationLaunchOptions.UnlockPasswordFromStandardInputConfigurationKey}={TestConstants.True}", $"--{ApplicationLaunchOptions.NonInteractiveConfigurationKey}={TestConstants.True}", StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([$"--{ApplicationLaunchOptions.UnlockPasswordFromStandardInputConfigurationKey}={TestConstants.True}", $"--{ApplicationLaunchOptions.NonInteractiveConfigurationKey}={TestConstants.True}", StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, applicationUnlocker, applicationMenu); - bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); + _ = Assert.ThrowsAsync(() => runner.TryRunAsync(CancellationToken.None)); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(applicationUnlocker.UnlockCallCount, Is.EqualTo(1)); Assert.That(applicationUnlocker.LastReadPasswordFromStandardInput, Is.True); Assert.That(applicationMenu.InvokeCommandCallCount, Is.Zero); @@ -266,45 +265,41 @@ public async Task TryRunAsyncWhenNonInteractiveUnlockFailsDoesNotExecuteCommand( } [Test] - public async Task TryRunAsyncWhenUnlockPasswordFromStandardInputIsMissingForLockedNonInteractiveStartupCommandDoesNotUnlockOrExecuteCommand() + public void TryRunAsyncWhenUnlockPasswordFromStandardInputIsMissingForLockedNonInteractiveStartupCommandDoesNotUnlockOrExecuteCommand() { IOptions launchOptions = CreateLaunchOptions(nonInteractive: true); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - FakeApplicationOutputWriter outputWriter = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, outputWriter, applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]), launchOptions, applicationUnlocker, applicationMenu); - bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); + ApplicationCommandException? exception = Assert.ThrowsAsync(() => runner.TryRunAsync(CancellationToken.None)); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(applicationUnlocker.UnlockCallCount, Is.Zero); Assert.That(applicationMenu.InvokeCommandCallCount, Is.Zero); - Assert.That(outputWriter.LastOutput, Is.TypeOf()); - Assert.That(((StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput)outputWriter.LastOutput!).CommandName, Is.EqualTo(TestConstants.ListAccountsCommand)); + Assert.That(exception!.Output, Is.TypeOf()); + Assert.That(((StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput)exception.Output!).CommandName, Is.EqualTo(TestConstants.ListAccountsCommand)); }); } [Test] - public async Task TryRunAsyncWhenLockedNonInteractiveStartupCommandHasExplicitUnlockPasswordFromStandardInputFalseWritesWarningAndSkipsCommand() + public void TryRunAsyncWhenLockedNonInteractiveStartupCommandHasExplicitUnlockPasswordFromStandardInputFalseReportsFailureAndSkipsCommand() { RawCommandLineArguments commandLineArguments = new([$"--{ApplicationLaunchOptions.UnlockPasswordFromStandardInputConfigurationKey}={TestConstants.False}", $"--{ApplicationLaunchOptions.NonInteractiveConfigurationKey}={TestConstants.True}", StartupCommandArguments.CommandDelimiter, TestConstants.ListAccountsCommand]); IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - FakeApplicationOutputWriter outputWriter = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, outputWriter, applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); - bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); + ApplicationCommandException? exception = Assert.ThrowsAsync(() => runner.TryRunAsync(CancellationToken.None)); Assert.Multiple(() => { - Assert.That(result, Is.True); Assert.That(applicationUnlocker.UnlockCallCount, Is.Zero); Assert.That(applicationMenu.InvokeCommandCallCount, Is.Zero); - Assert.That(outputWriter.LastOutput, Is.TypeOf()); - Assert.That(((StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput)outputWriter.LastOutput!).CommandName, Is.EqualTo(TestConstants.ListAccountsCommand)); + Assert.That(exception!.Output, Is.TypeOf()); + Assert.That(((StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput)exception.Output!).CommandName, Is.EqualTo(TestConstants.ListAccountsCommand)); }); } @@ -328,7 +323,7 @@ public async Task TryRunAsyncWhenStartupCommandHasCommandSpecificArgumentsPreser IOptions launchOptions = TestApplicationFactory.CreateLaunchOptionsOptions(TestApplicationFactory.CreateLaunchOptionsFromCommandLine(commandLineArguments.Values.ToArray())); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, commandLineArguments, launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -348,7 +343,7 @@ public async Task TryRunAsyncWhenStartupCommandIsUnknownDoesNotAttemptUnlockAndS IOptions launchOptions = CreateLaunchOptions(unlockPasswordFromStandardInput: true, nonInteractive: true); FakeApplicationUnlocker applicationUnlocker = new(); FakeApplicationMenu applicationMenu = new(); - StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, "unknown-command"]), launchOptions, new FakeApplicationOutputWriter(), applicationUnlocker, applicationMenu); + StartupCommandRunner runner = new(NullLogger.Instance, new RawCommandLineArguments([StartupCommandArguments.CommandDelimiter, "unknown-command"]), launchOptions, applicationUnlocker, applicationMenu); bool result = await runner.TryRunAsync(CancellationToken.None).ConfigureAwait(false); @@ -369,14 +364,19 @@ private static IOptions CreateLaunchOptions(bool unloc private sealed class FakeApplicationUnlocker : IApplicationUnlocker { internal int UnlockCallCount { get; private set; } + internal bool UnlockResult { get; init; } = true; + internal bool LastReadPasswordFromStandardInput { get; private set; } - public Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false) + public Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false) { UnlockCallCount++; LastReadPasswordFromStandardInput = readPasswordFromStandardInput; - return Task.FromResult(UnlockResult); + + return UnlockResult + ? Task.CompletedTask + : throw new ApplicationCommandException(new InvalidPasswordWarningOutput()); } } diff --git a/src/Eppie.CLI/Eppie.CLI.Tests/Services/TestConstants.cs b/src/Eppie.CLI/Eppie.CLI.Tests/Services/TestConstants.cs index d334e63f..6d2de3b1 100644 --- a/src/Eppie.CLI/Eppie.CLI.Tests/Services/TestConstants.cs +++ b/src/Eppie.CLI/Eppie.CLI.Tests/Services/TestConstants.cs @@ -41,6 +41,7 @@ internal static class TestConstants internal const string Contact1FullName = "Contact 1"; internal const string EmailPropertyName = "email"; internal const string ImapServerPropertyName = "imapServer"; + internal const string HumanVerificationTokenPropertyName = "humanVerificationToken"; internal const string AccountAddress = "account@eppie.io"; internal const string UserAddress = "user@example.com"; internal const string ToAddress = "to@example.com"; @@ -132,6 +133,8 @@ internal static class TestConstants internal const string JsonTextBodyProperty = "textBody"; internal const string JsonUnreadCountProperty = "unreadCount"; internal const string JsonTotalCountProperty = "totalCount"; + internal const string JsonVerificationUriProperty = "verificationUri"; + internal const string JsonHelpUriProperty = "helpUri"; internal const string JsonResultType = "result"; internal const string JsonStatusType = "status"; @@ -163,5 +166,9 @@ internal static class TestConstants internal const string AuthorizationCanceledCode = "authorizationCanceled"; internal const string AuthorizationStartedCode = "authorizationStarted"; internal const string AuthorizationCompletedCode = "authorizationCompleted"; + internal const string HumanVerificationRequiredCode = "humanVerificationRequired"; + internal const string StandardInputEndedCode = "standardInputEnded"; + + internal const string ProtonCaptchaUri = "https://mail-api.proton.me/core/v4/captcha?Token=test-token"; } } diff --git a/src/Eppie.CLI/Eppie.CLI/Exceptions/ApplicationCommandException.cs b/src/Eppie.CLI/Eppie.CLI/Exceptions/ApplicationCommandException.cs index 7a84aaf2..9bad0047 100644 --- a/src/Eppie.CLI/Eppie.CLI/Exceptions/ApplicationCommandException.cs +++ b/src/Eppie.CLI/Eppie.CLI/Exceptions/ApplicationCommandException.cs @@ -22,6 +22,8 @@ namespace Eppie.CLI.Exceptions { internal sealed class ApplicationCommandException : Exception { + internal const int FailureExitCode = 1; + public ApplicationCommandException() { } @@ -36,20 +38,17 @@ public ApplicationCommandException(string? message, Exception? innerException) { } - internal ApplicationCommandException(ApplicationOutput output, int exitCode, bool logStackTrace = false, Exception? innerException = null) + internal ApplicationCommandException(ApplicationOutput output, bool logStackTrace = false, Exception? innerException = null) : base(message: null, innerException) { ArgumentNullException.ThrowIfNull(output); Output = output; - ExitCode = exitCode; LogStackTrace = logStackTrace; } internal ApplicationOutput? Output { get; } - internal int ExitCode { get; } - internal bool LogStackTrace { get; } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Exceptions/InputCanceledByUserException.cs b/src/Eppie.CLI/Eppie.CLI/Exceptions/InputCanceledByUserException.cs new file mode 100644 index 00000000..34d22975 --- /dev/null +++ b/src/Eppie.CLI/Eppie.CLI/Exceptions/InputCanceledByUserException.cs @@ -0,0 +1,34 @@ +// ---------------------------------------------------------------------------- // +// // +// Copyright 2026 Eppie (https://eppie.io) // +// // +// 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. // +// // +// ---------------------------------------------------------------------------- // + +namespace Eppie.CLI.Exceptions +{ + internal class InputCanceledByUserException : Exception + { + internal InputCanceledByUserException() + { } + + internal InputCanceledByUserException(string message) : base(message) + { + } + + internal InputCanceledByUserException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Eppie.CLI/Eppie.CLI/Menu/Actions.cs b/src/Eppie.CLI/Eppie.CLI/Menu/Actions.cs index 8559e472..b205f6f9 100644 --- a/src/Eppie.CLI/Eppie.CLI/Menu/Actions.cs +++ b/src/Eppie.CLI/Eppie.CLI/Menu/Actions.cs @@ -405,33 +405,35 @@ async Task CreateAccountAsync() : await CreateOAuth2AccountAsync(mailServer).ConfigureAwait(false); } + Account account; + try { - Account account = await CreateAccountAsync().ConfigureAwait(false); - - ICredentialsProvider outgoingCredentialsProvider = _coreProvider.TuviMailCore.CredentialsManager.CreateOutgoingCredentialsProvider(account); - await _coreProvider.TuviMailCore.TestMailServerAsync( - account.OutgoingServerAddress, - account.OutgoingServerPort, - account.OutgoingMailProtocol, - outgoingCredentialsProvider - ).ConfigureAwait(false); - - ICredentialsProvider incomingCredentialsProvider = _coreProvider.TuviMailCore.CredentialsManager.CreateIncomingCredentialsProvider(account); - await _coreProvider.TuviMailCore.TestMailServerAsync( - account.IncomingServerAddress, - account.IncomingServerPort, - account.IncomingMailProtocol, - incomingCredentialsProvider).ConfigureAwait(false); - - await _coreProvider.TuviMailCore.AddAccountAsync(account).ConfigureAwait(false); - - _outputWriter.Write(new AccountAddedOutput(account.Email.Address, account.Type.ToString())); + account = await CreateAccountAsync().ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - WriteAuthorizationCanceledMessage(); + throw new ApplicationCommandException(new AuthorizationCanceledOutput(), innerException: ex); } + + ICredentialsProvider outgoingCredentialsProvider = _coreProvider.TuviMailCore.CredentialsManager.CreateOutgoingCredentialsProvider(account); + await _coreProvider.TuviMailCore.TestMailServerAsync( + account.OutgoingServerAddress, + account.OutgoingServerPort, + account.OutgoingMailProtocol, + outgoingCredentialsProvider + ).ConfigureAwait(false); + + ICredentialsProvider incomingCredentialsProvider = _coreProvider.TuviMailCore.CredentialsManager.CreateIncomingCredentialsProvider(account); + await _coreProvider.TuviMailCore.TestMailServerAsync( + account.IncomingServerAddress, + account.IncomingServerPort, + account.IncomingMailProtocol, + incomingCredentialsProvider).ConfigureAwait(false); + + await _coreProvider.TuviMailCore.AddAccountAsync(account).ConfigureAwait(false); + + _outputWriter.Write(new AccountAddedOutput(account.Email.Address, account.Type.ToString())); } private Account CreateDefaultAccount() @@ -510,15 +512,26 @@ private async Task AddProtonAccountAsync(MenuCommand.CommandAddAccountOptions.Op { _logger.LogMethodCall(); - IProtonAccountInput input = await _protonAccountInputResolver.ResolveAsync(options.InputJsonFromStandardInput).ConfigureAwait(false); + ProtonLoginState state = new(); + IProtonAccountInput input; + ProtonCredentials protonCredentials; - ProtonCredentials protonCredentials = await _protonLoginHelper.LoginAsync( - input.Email, - input.AccountPassword, - (ex, ct) => Task.FromResult((true, input.GetTwoFactorCode(ex is null))), - (ex, ct) => Task.FromResult((true, input.GetMailboxPassword(ex is null))), - (uri, ex, ct) => ProvideProtonHumanVerificationToken(input, uri), - default).ConfigureAwait(false); + try + { + input = await _protonAccountInputResolver.ResolveAsync(options.InputJsonFromStandardInput).ConfigureAwait(false); + + protonCredentials = await _protonLoginHelper.LoginAsync( + input.Email, + input.AccountPassword, + (ex, ct) => ProvideProtonTwoFactorCode(input, state, ex), + (ex, ct) => ProvideProtonMailboxPassword(input, state, ex), + (uri, ex, ct) => ProvideProtonHumanVerificationToken(input, state, uri, ex), + default).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (state.Declined) + { + throw new ApplicationCommandException(new AuthorizationCanceledOutput(), innerException: ex); + } Account account = Account.Default; @@ -535,14 +548,102 @@ private async Task AddProtonAccountAsync(MenuCommand.CommandAddAccountOptions.Op _outputWriter.Write(new AccountAddedOutput(account.Email.Address, account.Type.ToString())); } - private Task<(bool completed, string verificationType, string token)> ProvideProtonHumanVerificationToken(IProtonAccountInput input, Uri verificationUri) + private sealed class ProtonLoginState + { + internal bool Declined { get; set; } + } + + private Task<(bool completed, string code)> ProvideProtonTwoFactorCode(IProtonAccountInput input, ProtonLoginState state, Exception? previousAttemptException) { _logger.LogMethodCall(); + ArgumentNullException.ThrowIfNull(input); + if (!CanAskProtonInputAgain(input, state, previousAttemptException)) + { + return Task.FromResult((false, string.Empty)); + } + + string code = input.GetTwoFactorCode(previousAttemptException is null).Trim(); + + if (code.Length == 0) + { + state.Declined = true; + return Task.FromResult((false, string.Empty)); + } + + return Task.FromResult((true, code)); + } + + private Task<(bool completed, string password)> ProvideProtonMailboxPassword(IProtonAccountInput input, ProtonLoginState state, Exception? previousAttemptException) + { + _logger.LogMethodCall(); + + ArgumentNullException.ThrowIfNull(input); + + if (!CanAskProtonInputAgain(input, state, previousAttemptException)) + { + return Task.FromResult((false, string.Empty)); + } + + string password = input.GetMailboxPassword(previousAttemptException is null); + + if (password.Length == 0) + { + state.Declined = true; + return Task.FromResult((false, string.Empty)); + } + + return Task.FromResult((true, password)); + } + + private bool CanAskProtonInputAgain(IProtonAccountInput input, ProtonLoginState state, Exception? previousAttemptException) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(state); + + if (previousAttemptException is null || input.SupportsRetry) + { + return true; + } + + _outputWriter.Write(new UnsuccessfulAttemptWarningOutput()); + state.Declined = true; + return false; + } + + private Task<(bool completed, string verificationType, string token)> ProvideProtonHumanVerificationToken(IProtonAccountInput input, + ProtonLoginState state, + Uri verificationUri, + Exception? previousAttemptException) + { + _logger.LogMethodCall(); + + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(state); + + if (previousAttemptException is not null) + { + _outputWriter.Write(new UnsuccessfulAttemptWarningOutput()); + } + _outputWriter.Write(new ProtonHumanVerificationRequiredOutput(verificationUri)); - return Task.FromResult((true, ProtonHumanVerificationType, input.GetHumanVerificationToken())); + if (previousAttemptException is not null && !input.SupportsRetry) + { + state.Declined = true; + return Task.FromResult((false, string.Empty, string.Empty)); + } + + string token = input.GetHumanVerificationToken().Trim(); + + if (token.Length == 0) + { + state.Declined = true; + return Task.FromResult((false, string.Empty, string.Empty)); + } + + return Task.FromResult((true, ProtonHumanVerificationType, token)); } private async Task AddDecAccountAsync() @@ -573,7 +674,7 @@ private void OnCoreException(object? sender, ExceptionEventArgs e) ArgumentNullException.ThrowIfNull(e); - _failureHandler.HandleUnhandledException(e.Exception); + _failureHandler.ReportBackgroundException(e.Exception); } private void WriteApplicationInitializationMessage(IReadOnlyCollection seedPhrase) @@ -600,12 +701,6 @@ private void WriteSuccessfulRestoredMessage() _outputWriter.Write(new ApplicationRestoredOutput()); } - private void WriteAuthorizationCanceledMessage() - { - _logger.LogDebug("Authorization operation was canceled."); - _outputWriter.Write(new AuthorizationCanceledOutput()); - } - private void WriteAuthorizationToServiceMessage(string serviceName) { _logger.LogDebug("Authorization to {ServiceName} service starting.", serviceName); @@ -620,27 +715,27 @@ private void WriteAuthorizationCompletedMessage() private static void ThrowInvalidPasswordWarning() { - throw new ApplicationCommandException(new InvalidPasswordWarningOutput(), exitCode: 0); + throw new ApplicationCommandException(new InvalidPasswordWarningOutput()); } private static void ThrowSecondInitializationWarning() { - throw new ApplicationCommandException(new SecondInitializationWarningOutput(), exitCode: 0); + throw new ApplicationCommandException(new SecondInitializationWarningOutput()); } private static void ThrowUninitializedAppWarning() { - throw new ApplicationCommandException(new UninitializedAppWarningOutput(), exitCode: 0); + throw new ApplicationCommandException(new UninitializedAppWarningOutput()); } private static void ThrowImpossibleInitializationError() { - throw new ApplicationCommandException(new ImpossibleInitializationErrorOutput(), exitCode: 0); + throw new ApplicationCommandException(new ImpossibleInitializationErrorOutput()); } private static void ThrowUnknownFolderWarning(string address, string folder) { - throw new ApplicationCommandException(new UnknownFolderWarningOutput(address, folder), exitCode: 0); + throw new ApplicationCommandException(new UnknownFolderWarningOutput(address, folder)); } private void SubscribeToCoreExceptions() @@ -659,7 +754,7 @@ private bool ConfirmResetOrWarn(string commandName) ArgumentException.ThrowIfNullOrWhiteSpace(commandName); return _launchOptions.NonInteractive && !_launchOptions.AssumeYes - ? throw new ApplicationCommandException(new CommandRequiresAssumeYesInNonInteractiveModeWarningOutput(commandName), exitCode: 0) + ? throw new ApplicationCommandException(new CommandRequiresAssumeYesInNonInteractiveModeWarningOutput(commandName)) : _application.ConfirmReset(); } diff --git a/src/Eppie.CLI/Eppie.CLI/Menu/MainMenu.cs b/src/Eppie.CLI/Eppie.CLI/Menu/MainMenu.cs index 3c493551..654246fd 100644 --- a/src/Eppie.CLI/Eppie.CLI/Menu/MainMenu.cs +++ b/src/Eppie.CLI/Eppie.CLI/Menu/MainMenu.cs @@ -79,14 +79,25 @@ public async Task LoopAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { + string commandText; + try { - await InvokeCommandAsync(GetCommandParser(), _application.ReadValue($"{CommandMark} ")).ConfigureAwait(false); + commandText = _application.ReadValue($"{CommandMark} "); } catch (ReadValueCanceledException) { OnCancelCommand(); + _application.StopApplication(); + return; + } + + if (!Console.IsInputRedirected) + { + Environment.ExitCode = 0; } + + await InvokeCommandAsync(GetCommandParser(), commandText).ConfigureAwait(false); } } @@ -251,8 +262,13 @@ void HandleException(ICommand cmd) { HandleControlledCommandFailure(ex); } - catch (ReadValueCanceledException) + catch (ReadValueCanceledException ex) { + HandleControlledCommandFailure(new ApplicationCommandException(new StandardInputEndedErrorOutput(), innerException: ex)); + } + catch (InputCanceledByUserException) + { + Environment.ExitCode = ApplicationCommandException.FailureExitCode; OnCancelCommand(); } catch (Exception ex) @@ -282,8 +298,13 @@ async Task HandleException(IAsyncCommand cmd) { HandleControlledCommandFailure(ex); } - catch (ReadValueCanceledException) + catch (ReadValueCanceledException ex) + { + HandleControlledCommandFailure(new ApplicationCommandException(new StandardInputEndedErrorOutput(), innerException: ex)); + } + catch (InputCanceledByUserException) { + Environment.ExitCode = ApplicationCommandException.FailureExitCode; OnCancelCommand(); } catch (Exception ex) diff --git a/src/Eppie.CLI/Eppie.CLI/Resources/Program.resx b/src/Eppie.CLI/Eppie.CLI/Resources/Program.resx index 038327c9..729344ae 100644 --- a/src/Eppie.CLI/Eppie.CLI/Resources/Program.resx +++ b/src/Eppie.CLI/Eppie.CLI/Resources/Program.resx @@ -175,6 +175,14 @@ This message informs the user that the requested operation requires interactive input. {0} is the operation description. + + Error: This command needs interactive input, which cannot share the output stream with '--output=json'. Add '--non-interactive=true' and pass the values through standard input, or drop '--output=json'. + This message informs the user that a command requiring interactive input cannot run while structured output is enabled. + + + Error: Standard input ended before all the required values were supplied. + This message informs the user that the command stopped because standard input ran out while a value was still being read. + Error: The remaining standard input for the '{0}' command is not valid JSON. This message informs the user that the remaining standard input cannot be parsed as JSON. @@ -530,8 +538,8 @@ This message prompts the user to select one of the options. {0} is the parameter containing the default option. - - Authorization operation has been canceled. + + Error: Authorization operation has been canceled. This message informs the user that the authorization operation was canceled. diff --git a/src/Eppie.CLI/Eppie.CLI/Services/Application.cs b/src/Eppie.CLI/Eppie.CLI/Services/Application.cs index c769eeed..3136e827 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/Application.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/Application.cs @@ -40,6 +40,8 @@ internal class Application( IOptions mailOptions, ResourceLoader resourceLoader) : IApplicationPasswordReader { + private const string MessageBodyTerminator = "EOF"; + private readonly ResourceLoader _resourceLoader = resourceLoader; private readonly ILogger _logger = logger; private readonly IHostApplicationLifetime _lifetime = lifetime; @@ -47,6 +49,38 @@ internal class Application( private readonly IApplicationOutputWriter _outputWriter = outputWriter; private readonly IOptions _mailOptions = mailOptions; + internal bool CanAskForNewValues => IsPromptVisible && !Console.IsInputRedirected; + + private bool IsPromptVisible => !_launchOptions.NonInteractive; + + private void EnsureInteractiveInputIsAvailable() + { + if (!_launchOptions.NonInteractive && _outputWriter.Format == ApplicationOutputFormat.Json) + { + throw new ApplicationCommandException(new InteractiveInputNotSupportedErrorOutput()); + } + } + + private string ReadNamedValue(string message, string inputName, ConsoleColor foreground = ConsoleColor.Gray) + { + return IsPromptVisible ? ReadValue(message, foreground) : ReadAnnouncedValue(message, inputName, foreground); + } + + private string ReadNamedSecret(string message, string inputName, ConsoleColor foreground = ConsoleColor.Gray) + { + return IsPromptVisible ? ReadSecretValue(message, foreground) : ReadAnnouncedValue(message, inputName, foreground); + } + + private string ReadAnnouncedValue(string message, string inputName, ConsoleColor foreground) + { + if (_outputWriter.Format == ApplicationOutputFormat.Json) + { + _outputWriter.Write(new InputRequiredOutput(inputName)); + } + + return ReadValue(message, writePrompt: false, foreground); + } + internal void StopApplication() { _logger.LogMethodCall(); @@ -57,16 +91,14 @@ internal string AskPassword() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadPasswordFromStandardInput() - : ReadSecretValue(_resourceLoader.Strings.AskPassword); + return ReadNamedSecret(_resourceLoader.Strings.AskPassword, InputName.VaultPassword); } internal string ReadPasswordFromStandardInput() { _logger.LogMethodCall(); - return ReadValue(_resourceLoader.Strings.AskPassword, writePrompt: !_launchOptions.NonInteractive); + return ReadNamedValue(_resourceLoader.Strings.AskPassword, InputName.VaultPassword); } string IApplicationPasswordReader.AskPassword() @@ -83,36 +115,28 @@ internal string AskNewPassword() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.AskNewPassword, writePrompt: false) - : ReadSecretValue(_resourceLoader.Strings.AskNewPassword); + return ReadNamedSecret(_resourceLoader.Strings.AskNewPassword, InputName.NewVaultPassword); } internal string ConfirmPassword() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.ConfirmPassword, writePrompt: false) - : ReadSecretValue(_resourceLoader.Strings.ConfirmPassword); + return ReadNamedSecret(_resourceLoader.Strings.ConfirmPassword, InputName.VaultPasswordConfirmation); } internal string AskAccountAddress() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.AskAccountAddress, writePrompt: false) - : ReadValue(_resourceLoader.Strings.AskAccountAddress); + return ReadNamedValue(_resourceLoader.Strings.AskAccountAddress, InputName.AccountAddress); } internal string AskAccountPassword() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.AskAccountPassword, writePrompt: false) - : ReadSecretValue(_resourceLoader.Strings.AskAccountPassword); + return ReadNamedSecret(_resourceLoader.Strings.AskAccountPassword, InputName.AccountPassword); } internal string AskTwoFactorCode(bool firstAttempt) @@ -124,7 +148,7 @@ internal string AskTwoFactorCode(bool firstAttempt) _outputWriter.Write(new UnsuccessfulAttemptWarningOutput()); } - return ReadValue(_resourceLoader.Strings.AskTwoFactorCode); + return ReadNamedValue(_resourceLoader.Strings.AskTwoFactorCode, InputName.TwoFactorCode); } internal string AskMailboxPassword(bool firstAttempt) @@ -136,16 +160,14 @@ internal string AskMailboxPassword(bool firstAttempt) _outputWriter.Write(new UnsuccessfulAttemptWarningOutput()); } - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.AskMailboxPassword, writePrompt: false) - : ReadSecretValue(_resourceLoader.Strings.AskMailboxPassword); + return ReadNamedSecret(_resourceLoader.Strings.AskMailboxPassword, InputName.MailboxPassword); } internal string AskHumanVerificationToken() { _logger.LogMethodCall(); - return ReadValue(_resourceLoader.Strings.AskHumanVerificationToken, writePrompt: !_launchOptions.NonInteractive); + return ReadNamedValue(_resourceLoader.Strings.AskHumanVerificationToken, InputName.HumanVerificationToken); } internal string AskIMAPServer(MailServer mailServer) @@ -153,7 +175,7 @@ internal string AskIMAPServer(MailServer mailServer) _logger.LogMethodCall(); MailServerConfiguration config = GetMailServerConfiguration(mailServer); - return AskQuestionWithDefault(_resourceLoader.Strings.GetIMAPServerQuestionText(config.IMAP), config.IMAP); + return AskQuestionWithDefault(_resourceLoader.Strings.GetIMAPServerQuestionText(config.IMAP), InputName.ImapServer, config.IMAP); } internal string AskSMTPServer(MailServer mailServer) @@ -161,7 +183,7 @@ internal string AskSMTPServer(MailServer mailServer) _logger.LogMethodCall(); MailServerConfiguration config = GetMailServerConfiguration(mailServer); - return AskQuestionWithDefault(_resourceLoader.Strings.GetSMTPServerQuestionText(config.SMTP), config.SMTP); + return AskQuestionWithDefault(_resourceLoader.Strings.GetSMTPServerQuestionText(config.SMTP), InputName.SmtpServer, config.SMTP); } internal int AskIMAPServerPort(MailServer mailServer) @@ -169,7 +191,7 @@ internal int AskIMAPServerPort(MailServer mailServer) _logger.LogMethodCall(); MailServerConfiguration config = GetMailServerConfiguration(mailServer); - return AskQuestionWithDefault(_resourceLoader.Strings.GetIMAPPortQuestionText(config.IMAPPort), config.IMAPPort); + return AskQuestionWithDefault(_resourceLoader.Strings.GetIMAPPortQuestionText(config.IMAPPort), InputName.ImapPort, config.IMAPPort); } internal int AskSMTPServerPort(MailServer mailServer) @@ -177,23 +199,23 @@ internal int AskSMTPServerPort(MailServer mailServer) _logger.LogMethodCall(); MailServerConfiguration config = GetMailServerConfiguration(mailServer); - return AskQuestionWithDefault(_resourceLoader.Strings.GetSMTPPortQuestionText(config.SMTPPort), config.SMTPPort); + return AskQuestionWithDefault(_resourceLoader.Strings.GetSMTPPortQuestionText(config.SMTPPort), InputName.SmtpPort, config.SMTPPort); } - internal int AskQuestionWithDefault(string text, int defaultValue) + internal int AskQuestionWithDefault(string text, string inputName, int defaultValue) { _logger.LogMethodCall(); - return int.TryParse(ReadValue(text), out int port) && port > 0 + return int.TryParse(ReadNamedValue(text, inputName), out int port) && port > 0 ? port : defaultValue; } - internal string AskQuestionWithDefault(string text, string defaultValue) + internal string AskQuestionWithDefault(string text, string inputName, string defaultValue) { _logger.LogMethodCall(); - string answer = ReadValue(text); + string answer = ReadNamedValue(text, inputName); return string.IsNullOrEmpty(answer) ? defaultValue : answer; } @@ -206,16 +228,14 @@ internal string AskSeedPhrase() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadValue(_resourceLoader.Strings.AskSeedPhrase, writePrompt: false) - : ReadSecretValue(_resourceLoader.Strings.AskSeedPhrase); + return ReadNamedSecret(_resourceLoader.Strings.AskSeedPhrase, InputName.SeedPhrase); } internal string AskRestorePath() { _logger.LogMethodCall(); - return ReadValue(_resourceLoader.Strings.AskRestorePath); + return ReadNamedValue(_resourceLoader.Strings.AskRestorePath, InputName.RestorePath); } internal TEnum SelectOption(TEnum defaultOption, bool ignoreCase = false) @@ -225,9 +245,11 @@ internal TEnum SelectOption(TEnum defaultOption, bool ignoreCase = false) if (_launchOptions.NonInteractive) { - throw new InvalidOperationException(_resourceLoader.Strings.GetNonInteractiveOperationNotSupportedError("option selection")); + throw new ApplicationCommandException(new NonInteractiveOperationNotSupportedErrorOutput("option selection")); } + EnsureInteractiveInputIsAvailable(); + Console.WriteLine(_resourceLoader.Strings.SelectOptionHeader); int i = 0; @@ -266,9 +288,14 @@ internal string AskMessageBody() { _logger.LogMethodCall(); - return _launchOptions.NonInteractive - ? ReadRemainingStandardInput() - : ConsoleExtension.ReadMultiLine(_resourceLoader.Strings.AskMessageBody, "EOF") ?? throw new ReadValueCanceledException(); + if (_launchOptions.NonInteractive) + { + return ReadRemainingStandardInput(); + } + + EnsureInteractiveInputIsAvailable(); + + return ConsoleExtension.ReadMultiLine(_resourceLoader.Strings.AskMessageBody, MessageBodyTerminator) ?? throw new ReadValueCanceledException(); } internal string GetPrintAllMessagesHeader() @@ -294,13 +321,18 @@ internal string GetPrintContactMessagesHeader(string contactAddress) internal string ReadValue(string message, ConsoleColor foreground = ConsoleColor.Gray) { - return ReadValue(message, writePrompt: true, foreground); + return ReadValue(message, writePrompt: IsPromptVisible, foreground); } internal string ReadValue(string message, bool writePrompt, ConsoleColor foreground = ConsoleColor.Gray) { _logger.LogMethodCall(); + if (writePrompt) + { + EnsureInteractiveInputIsAvailable(); + } + return ConsoleExtension.ReadValue(writePrompt ? message : string.Empty, (message) => { @@ -336,9 +368,13 @@ private string ReadSecretValue(string message, ConsoleColor foreground = Console { _logger.LogMethodCall(); + EnsureInteractiveInputIsAvailable(); + try { - return ConsoleExtension.ReadValue(message, (message) => ConsoleExtension.Write(message, foreground), () => ConsoleExtension.ReadSecretLine()) ?? throw new ReadValueCanceledException(); + return ConsoleExtension.ReadValue(message, + (message) => ConsoleExtension.Write(message, foreground), + () => ConsoleExtension.ReadSecretLine()) ?? throw new InputCanceledByUserException(); } catch (Exception ex) when (ex is IOException or InvalidOperationException) { @@ -350,9 +386,14 @@ private bool ReadBoolValue(string message, ConsoleColor foreground = ConsoleColo { _logger.LogMethodCall(); - return !_launchOptions.NonInteractive - ? ConsoleExtension.ReadBool(message, (message) => ConsoleExtension.Write(message, foreground)) - : throw new InvalidOperationException(_resourceLoader.Strings.GetNonInteractiveOperationNotSupportedError("confirmation prompt")); + if (_launchOptions.NonInteractive) + { + throw new ApplicationCommandException(new NonInteractiveOperationNotSupportedErrorOutput("confirmation prompt")); + } + + EnsureInteractiveInputIsAvailable(); + + return ConsoleExtension.ReadBool(message, (message) => ConsoleExtension.Write(message, foreground)); } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationFailureHandler.cs b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationFailureHandler.cs index 1815e22d..97fc4c82 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationFailureHandler.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationFailureHandler.cs @@ -46,18 +46,25 @@ public void HandleControlledCommandFailure(ApplicationCommandException exception } else { - _logger.LogDebug("Command failed with controlled output {OutputType} and exit code {ExitCode}", exception.Output.GetType().Name, exception.ExitCode); + _logger.LogDebug("Command failed with controlled output {OutputType}", exception.Output.GetType().Name); } - if (_launchOptions.NonInteractive) - { - Environment.ExitCode = exception.ExitCode; - } + Environment.ExitCode = ApplicationCommandException.FailureExitCode; _outputWriter.Write(exception.Output); } public void HandleUnhandledException(Exception exception) + { + ReportException(exception, commandFailed: true); + } + + public void ReportBackgroundException(Exception exception) + { + ReportException(exception, commandFailed: false); + } + + private void ReportException(Exception exception, bool commandFailed) { ArgumentNullException.ThrowIfNull(exception); @@ -70,6 +77,11 @@ public void HandleUnhandledException(Exception exception) _logger.LogError("An error has occurred {Exception}", exception); } + if (commandFailed) + { + Environment.ExitCode = ApplicationCommandException.FailureExitCode; + } + _outputWriter.Write(new UnhandledExceptionOutput(exception)); } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationMenuLoop.cs b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationMenuLoop.cs index 66d99d2d..8b848565 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationMenuLoop.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationMenuLoop.cs @@ -18,6 +18,7 @@ using System.Diagnostics.CodeAnalysis; +using Eppie.CLI.Exceptions; using Eppie.CLI.Tools; using Microsoft.Extensions.Hosting; @@ -32,7 +33,7 @@ internal partial class ApplicationMenuLoop( IHostApplicationLifetime lifetime, IOptions launchOptions, IStartupCommandRunner startupCommandRunner, - IApplicationOutputWriter outputWriter, + IApplicationFailureHandler failureHandler, Menu.IApplicationMenu applicationMenu) : BackgroundService { private const string InteractiveMenuOperationName = "interactive menu"; @@ -41,32 +42,72 @@ internal partial class ApplicationMenuLoop( private readonly IHostApplicationLifetime _lifetime = lifetime; private readonly ApplicationLaunchOptions _launchOptions = launchOptions.Value; private readonly IStartupCommandRunner _startupCommandRunner = startupCommandRunner; - private readonly IApplicationOutputWriter _outputWriter = outputWriter; + private readonly IApplicationFailureHandler _failureHandler = failureHandler; private readonly Menu.IApplicationMenu _applicationMenu = applicationMenu; + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "to report the failure instead of letting it escape the background service")] protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogMethodCall(); await Task.Yield(); - if (!stoppingToken.IsCancellationRequested && await _startupCommandRunner.TryRunAsync(stoppingToken).ConfigureAwait(false)) + try { - _lifetime.StopApplication(); - return; - } + if (_launchOptions.OutputFormat == ApplicationOutputFormat.Json && !_launchOptions.NonInteractive) + { + throw new ApplicationCommandException(new InteractiveInputNotSupportedErrorOutput()); + } - if (!stoppingToken.IsCancellationRequested) - { - if (_launchOptions.NonInteractive) + if (!stoppingToken.IsCancellationRequested && await _startupCommandRunner.TryRunAsync(stoppingToken).ConfigureAwait(false)) { - _outputWriter.Write(new NonInteractiveOperationNotSupportedErrorOutput(InteractiveMenuOperationName)); _lifetime.StopApplication(); return; } - using CancellationTokenSource cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _lifetime.ApplicationStopping); - await _applicationMenu.LoopAsync(cancellationTokenSource.Token).ConfigureAwait(false); + if (!stoppingToken.IsCancellationRequested) + { + if (_launchOptions.NonInteractive) + { + throw new ApplicationCommandException(new NonInteractiveOperationNotSupportedErrorOutput(InteractiveMenuOperationName)); + } + + using CancellationTokenSource cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _lifetime.ApplicationStopping); + await _applicationMenu.LoopAsync(cancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (ReadValueCanceledException ex) + { + Fail(new ApplicationCommandException(new StandardInputEndedErrorOutput(), innerException: ex)); } + catch (ApplicationCommandException ex) when (ex.Output is not null) + { + Fail(ex); + } + catch (InputCanceledByUserException) + { + Abort(); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + Abort(); + } + catch (Exception ex) + { + _failureHandler.HandleUnhandledException(ex); + _lifetime.StopApplication(); + } + } + + private void Abort() + { + Environment.ExitCode = ApplicationCommandException.FailureExitCode; + _lifetime.StopApplication(); + } + + private void Fail(ApplicationCommandException exception) + { + _failureHandler.HandleControlledCommandFailure(exception); + _lifetime.StopApplication(); } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationOutput.cs b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationOutput.cs index 74a9e7aa..de2de53f 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationOutput.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationOutput.cs @@ -48,7 +48,10 @@ internal sealed record AuthorizationToServiceOutput(string ServiceName) : Applic internal sealed record AuthorizationCompletedOutput() : ApplicationOutput; - internal sealed record ProtonHumanVerificationRequiredOutput(Uri VerificationUri) : ApplicationOutput; + internal sealed record ProtonHumanVerificationRequiredOutput(Uri VerificationUri) : ApplicationOutput + { + internal static Uri HelpUri { get; } = new("https://github.com/Eppie-io/Eppie-CLI/blob/main/docs/eppie-cli-agent-skill.md#proton-human-verification-captcha"); + } internal sealed record InvalidPasswordWarningOutput() : ApplicationOutput; @@ -58,6 +61,8 @@ internal sealed record UninitializedAppWarningOutput() : ApplicationOutput; internal sealed record UnsuccessfulAttemptWarningOutput() : ApplicationOutput; + internal sealed record InputRequiredOutput(string Input) : ApplicationOutput; + internal sealed record UnknownFolderWarningOutput(string Address, string Folder) : ApplicationOutput; internal sealed record CommandRequiresAssumeYesInNonInteractiveModeWarningOutput(string CommandName) : ApplicationOutput; @@ -78,6 +83,10 @@ internal sealed record FolderSyncedOutput(string AccountAddress, string FolderNa internal sealed record NonInteractiveOperationNotSupportedErrorOutput(string Operation) : ApplicationOutput; + internal sealed record StandardInputEndedErrorOutput() : ApplicationOutput; + + internal sealed record InteractiveInputNotSupportedErrorOutput() : ApplicationOutput; + internal sealed record ImpossibleInitializationErrorOutput() : ApplicationOutput; internal sealed record UnhandledExceptionOutput(Exception Exception) : ApplicationOutput; diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationUnlocker.cs b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationUnlocker.cs index 09392881..8d43a021 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ApplicationUnlocker.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ApplicationUnlocker.cs @@ -18,6 +18,7 @@ using System.Diagnostics.CodeAnalysis; +using Eppie.CLI.Exceptions; using Eppie.CLI.Tools; using Microsoft.Extensions.Logging; @@ -28,15 +29,13 @@ namespace Eppie.CLI.Services internal sealed class ApplicationUnlocker( ILogger logger, IApplicationPasswordReader passwordReader, - IApplicationOutputWriter outputWriter, ITuviMailCoreProvider coreProvider) : IApplicationUnlocker { private readonly ILogger _logger = logger; private readonly IApplicationPasswordReader _passwordReader = passwordReader; - private readonly IApplicationOutputWriter _outputWriter = outputWriter; private readonly ITuviMailCoreProvider _coreProvider = coreProvider; - public async Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false) + public async Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false) { _logger.LogMethodCall(); @@ -45,8 +44,7 @@ public async Task UnlockAsync(CancellationToken cancellationToken, bool re if (isFirstTime) { _logger.LogWarning("The command failed. (Reason: The application hasn't been initialized yet)."); - _outputWriter.Write(new UninitializedAppWarningOutput()); - return false; + throw new ApplicationCommandException(new UninitializedAppWarningOutput()); } string password = readPasswordFromStandardInput @@ -58,10 +56,8 @@ public async Task UnlockAsync(CancellationToken cancellationToken, bool re if (!success) { _logger.LogWarning("The command failed. (Reason: Invalid Password)."); - _outputWriter.Write(new InvalidPasswordWarningOutput()); + throw new ApplicationCommandException(new InvalidPasswordWarningOutput()); } - - return success; } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/EmailAccountInputResolver.cs b/src/Eppie.CLI/Eppie.CLI/Services/EmailAccountInputResolver.cs index a1f2ae8d..908ca6fa 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/EmailAccountInputResolver.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/EmailAccountInputResolver.cs @@ -18,6 +18,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; using Eppie.CLI.Exceptions; @@ -54,7 +55,7 @@ public async Task ResolveAsync() } catch (JsonException ex) { - throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), exitCode: 1, innerException: ex); + throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), innerException: ex); } if (structuredInput is null) @@ -64,12 +65,12 @@ public async Task ResolveAsync() EmailStructuredStandardInput validatedStructuredInput = structuredInput!; - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.Email, "email"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.AccountPassword, "accountPassword"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.ImapServer, "imapServer"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.ImapPort, "imapPort"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.SmtpServer, "smtpServer"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.SmtpPort, "smtpPort"); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.Email, InputName.Email); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.AccountPassword, InputName.AccountPassword); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.ImapServer, InputName.ImapServer); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.ImapPort, InputName.ImapPort); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.SmtpServer, InputName.SmtpServer); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.SmtpPort, InputName.SmtpPort); Account account = Account.Default; account.Type = MailBoxType.Email; @@ -92,7 +93,7 @@ private static void EnsureStructuredInputPropertyIsPresent(string? value, string { if (string.IsNullOrWhiteSpace(value)) { - throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName), exitCode: 1); + throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName)); } } @@ -100,27 +101,33 @@ private static void EnsureStructuredInputPropertyIsPresent(int? value, string pr { if (!value.HasValue || value.Value <= 0) { - throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName), exitCode: 1); + throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName)); } } private static void ThrowStructuredStandardInputInvalidJsonError() { - throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), exitCode: 1); + throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName)); } private sealed record EmailStructuredStandardInput { + [JsonPropertyName(InputName.Email)] public string? Email { get; init; } + [JsonPropertyName(InputName.AccountPassword)] public string? AccountPassword { get; init; } + [JsonPropertyName(InputName.ImapServer)] public string? ImapServer { get; init; } + [JsonPropertyName(InputName.ImapPort)] public int? ImapPort { get; init; } + [JsonPropertyName(InputName.SmtpServer)] public string? SmtpServer { get; init; } + [JsonPropertyName(InputName.SmtpPort)] public int? SmtpPort { get; init; } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/IApplicationFailureHandler.cs b/src/Eppie.CLI/Eppie.CLI/Services/IApplicationFailureHandler.cs index 0bdf1ae0..b63b36c5 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/IApplicationFailureHandler.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/IApplicationFailureHandler.cs @@ -25,5 +25,7 @@ internal interface IApplicationFailureHandler void HandleControlledCommandFailure(ApplicationCommandException exception); void HandleUnhandledException(Exception exception); + + void ReportBackgroundException(Exception exception); } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/IApplicationUnlocker.cs b/src/Eppie.CLI/Eppie.CLI/Services/IApplicationUnlocker.cs index 7277d1c8..5df19622 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/IApplicationUnlocker.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/IApplicationUnlocker.cs @@ -20,6 +20,6 @@ namespace Eppie.CLI.Services { internal interface IApplicationUnlocker { - Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false); + Task UnlockAsync(CancellationToken cancellationToken, bool readPasswordFromStandardInput = false); } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/IProtonAccountInputResolver.cs b/src/Eppie.CLI/Eppie.CLI/Services/IProtonAccountInputResolver.cs index 9f2a5120..9605ebe0 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/IProtonAccountInputResolver.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/IProtonAccountInputResolver.cs @@ -25,6 +25,8 @@ internal interface IProtonAccountInputResolver internal interface IProtonAccountInput { + bool SupportsRetry { get; } + string Email { get; } string AccountPassword { get; } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/InputName.cs b/src/Eppie.CLI/Eppie.CLI/Services/InputName.cs new file mode 100644 index 00000000..185dfd07 --- /dev/null +++ b/src/Eppie.CLI/Eppie.CLI/Services/InputName.cs @@ -0,0 +1,39 @@ +// ---------------------------------------------------------------------------- // +// // +// Copyright 2026 Eppie (https://eppie.io) // +// // +// 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. // +// // +// ---------------------------------------------------------------------------- // + +namespace Eppie.CLI.Services +{ + internal static class InputName + { + internal const string VaultPassword = "vaultPassword"; + internal const string NewVaultPassword = "newVaultPassword"; + internal const string VaultPasswordConfirmation = "vaultPasswordConfirmation"; + internal const string SeedPhrase = "seedPhrase"; + internal const string Email = "email"; + internal const string RestorePath = "restorePath"; + internal const string AccountAddress = "accountAddress"; + internal const string AccountPassword = "accountPassword"; + internal const string MailboxPassword = "mailboxPassword"; + internal const string TwoFactorCode = "twoFactorCode"; + internal const string HumanVerificationToken = "humanVerificationToken"; + internal const string ImapServer = "imapServer"; + internal const string ImapPort = "imapPort"; + internal const string SmtpServer = "smtpServer"; + internal const string SmtpPort = "smtpPort"; + } +} diff --git a/src/Eppie.CLI/Eppie.CLI/Services/JsonApplicationOutputWriter.cs b/src/Eppie.CLI/Eppie.CLI/Services/JsonApplicationOutputWriter.cs index 3819a370..e52dc122 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/JsonApplicationOutputWriter.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/JsonApplicationOutputWriter.cs @@ -114,9 +114,6 @@ private static bool TryWriteStatus(ApplicationOutput output) case ApplicationRestoredOutput: WriteStatus("restored"); return true; - case AuthorizationCanceledOutput: - WriteStatus("authorizationCanceled"); - return true; case AuthorizationToServiceOutput authorizationToServiceOutput: WriteStatus("authorizationStarted", new { serviceName = authorizationToServiceOutput.ServiceName }); return true; @@ -132,8 +129,15 @@ private static bool TryWriteStatus(ApplicationOutput output) case FolderSyncedOutput folderSyncedOutput: WriteStatus("folderSynced", new { account = folderSyncedOutput.AccountAddress, folder = folderSyncedOutput.FolderName }); return true; + case InputRequiredOutput inputRequiredOutput: + WriteStatus("inputRequired", new { input = inputRequiredOutput.Input }); + return true; case ProtonHumanVerificationRequiredOutput humanVerificationRequiredOutput: - WriteStatus("humanVerificationRequired", new { verificationUri = humanVerificationRequiredOutput.VerificationUri }); + WriteStatus("humanVerificationRequired", new + { + verificationUri = humanVerificationRequiredOutput.VerificationUri, + helpUri = ProtonHumanVerificationRequiredOutput.HelpUri, + }); return true; default: return false; @@ -180,9 +184,18 @@ private bool TryWriteError(ApplicationOutput output) { switch (output) { + case InteractiveInputNotSupportedErrorOutput: + WriteError("interactiveInputNotSupported", _resourceLoader.Strings.InteractiveInputNotSupported); + return true; + case StandardInputEndedErrorOutput: + WriteError("standardInputEnded", _resourceLoader.Strings.StandardInputEnded); + return true; case ImpossibleInitializationErrorOutput: WriteError("impossibleInitialization", _resourceLoader.Strings.ImpossibleInitialization); return true; + case AuthorizationCanceledOutput: + WriteError("authorizationCanceled", _resourceLoader.Strings.AuthorizationCanceled); + return true; case NonInteractiveOperationNotSupportedErrorOutput nonInteractiveOperationNotSupportedOutput: WriteError("nonInteractiveOperationNotSupported", _resourceLoader.Strings.GetNonInteractiveOperationNotSupportedError(nonInteractiveOperationNotSupportedOutput.Operation), diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ProtonAccountInputResolver.cs b/src/Eppie.CLI/Eppie.CLI/Services/ProtonAccountInputResolver.cs index fc88f6da..2784b8ef 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ProtonAccountInputResolver.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ProtonAccountInputResolver.cs @@ -18,6 +18,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization; using Eppie.CLI.Exceptions; @@ -64,7 +65,7 @@ private async Task ReadStructuredStandardInputAsync() } catch (JsonException ex) { - throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), exitCode: 1, innerException: ex); + throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), innerException: ex); } if (structuredInput is null) @@ -74,8 +75,8 @@ private async Task ReadStructuredStandardInputAsync() ProtonStructuredStandardInput validatedStructuredInput = structuredInput!; - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.Email, "email"); - EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.AccountPassword, "accountPassword"); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.Email, InputName.Email); + EnsureStructuredInputPropertyIsPresent(validatedStructuredInput.AccountPassword, InputName.AccountPassword); return new StructuredStandardInputProtonAccountInput(validatedStructuredInput); } @@ -84,7 +85,7 @@ private static void EnsureStructuredInputPropertyIsPresent(string? value, string { if (string.IsNullOrWhiteSpace(value)) { - throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName), exitCode: 1); + throw new ApplicationCommandException(new StructuredStandardInputMissingPropertyErrorOutput(AddAccountCommandName, propertyName)); } } @@ -96,19 +97,24 @@ private static string GetRequiredStructuredInputPropertyValue(string? value, str private static void ThrowStructuredStandardInputInvalidJsonError() { - throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName), exitCode: 1); + throw new ApplicationCommandException(new StructuredStandardInputInvalidJsonErrorOutput(AddAccountCommandName)); } private sealed record ProtonStructuredStandardInput { + [JsonPropertyName(InputName.Email)] public string? Email { get; init; } + [JsonPropertyName(InputName.AccountPassword)] public string? AccountPassword { get; init; } + [JsonPropertyName(InputName.MailboxPassword)] public string? MailboxPassword { get; init; } + [JsonPropertyName(InputName.TwoFactorCode)] public string? TwoFactorCode { get; init; } + [JsonPropertyName(InputName.HumanVerificationToken)] public string? HumanVerificationToken { get; init; } } @@ -116,6 +122,8 @@ private sealed class InteractiveProtonAccountInput(string email, string accountP { private readonly Application _application = application; + public bool SupportsRetry => _application.CanAskForNewValues; + public string Email { get; } = email; public string AccountPassword { get; } = accountPassword; @@ -140,23 +148,25 @@ private sealed class StructuredStandardInputProtonAccountInput(ProtonStructuredS { private readonly ProtonStructuredStandardInput _input = input; - public string Email => GetRequiredStructuredInputPropertyValue(_input.Email, "email"); + public bool SupportsRetry => false; + + public string Email => GetRequiredStructuredInputPropertyValue(_input.Email, InputName.Email); - public string AccountPassword => GetRequiredStructuredInputPropertyValue(_input.AccountPassword, "accountPassword"); + public string AccountPassword => GetRequiredStructuredInputPropertyValue(_input.AccountPassword, InputName.AccountPassword); public string GetTwoFactorCode(bool firstAttempt) { - return GetRequiredStructuredInputPropertyValue(_input.TwoFactorCode, "twoFactorCode"); + return GetRequiredStructuredInputPropertyValue(_input.TwoFactorCode, InputName.TwoFactorCode); } public string GetMailboxPassword(bool firstAttempt) { - return GetRequiredStructuredInputPropertyValue(_input.MailboxPassword, "mailboxPassword"); + return GetRequiredStructuredInputPropertyValue(_input.MailboxPassword, InputName.MailboxPassword); } public string GetHumanVerificationToken() { - return GetRequiredStructuredInputPropertyValue(_input.HumanVerificationToken, "humanVerificationToken"); + return GetRequiredStructuredInputPropertyValue(_input.HumanVerificationToken, InputName.HumanVerificationToken); } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/ResourceLoader.cs b/src/Eppie.CLI/Eppie.CLI/Services/ResourceLoader.cs index ba5a6e76..9f1d8143 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/ResourceLoader.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/ResourceLoader.cs @@ -43,8 +43,6 @@ internal sealed class ResourceLoader(IStringLocalizer localiz /// internal class ProgramStringLoader { - private static readonly Uri ProtonHumanVerificationHelpUri = new("https://github.com/Eppie-io/Eppie-CLI/blob/main/docs/eppie-cli-agent-skill.md#proton-human-verification-captcha"); - private readonly IStringLocalizer _localizer; internal ProgramStringLoader(IStringLocalizer localizer) @@ -84,9 +82,9 @@ internal string GetLogo(string name, string version) internal string AskHumanVerificationToken => field ??= _localizer.LoadString(GetStringResourceName()); - internal string GetProtonHumanVerificationRequiredText(Uri verificationUri) + internal string GetProtonHumanVerificationRequiredText(Uri verificationUri, Uri helpUri) { - return _localizer.LoadFormattedString(GetStringResourceName(category: "Message", name: "ProtonHumanVerificationRequired"), verificationUri, ProtonHumanVerificationHelpUri); + return _localizer.LoadFormattedString(GetStringResourceName(category: "Message", name: "ProtonHumanVerificationRequired"), verificationUri, helpUri); } private string GetServerAddressQuestionText(string resourceName, string? defaultServer) @@ -151,7 +149,7 @@ internal string GetEmptyFolderList(string accountAddress) internal string AskMessageBody => field ??= _localizer.LoadString(GetStringResourceName()); - internal string AuthorizationCanceled => field ??= _localizer.LoadString(GetStringResourceName()); + internal string AuthorizationCanceled => field ??= _localizer.LoadString(GetStringResourceName(category: "Error")); internal string AuthorizationCompleted => field ??= _localizer.LoadString(GetStringResourceName()); @@ -200,6 +198,10 @@ internal string GetStructuredStandardInputMissingPropertyError(string commandNam internal string ImpossibleInitialization => field ??= _localizer.LoadString(GetStringResourceName(category: "Error")); + internal string StandardInputEnded => field ??= _localizer.LoadString(GetStringResourceName(category: "Error")); + + internal string InteractiveInputNotSupported => field ??= _localizer.LoadString(GetStringResourceName(category: "Error")); + internal string GetUnknownFolderWarning(string address, string folder) { return _localizer.LoadFormattedString(GetStringResourceName(category: "Warning", name: "UnknownFolder"), folder, address); diff --git a/src/Eppie.CLI/Eppie.CLI/Services/StartupCommandRunner.cs b/src/Eppie.CLI/Eppie.CLI/Services/StartupCommandRunner.cs index 54d21468..10fe5945 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/StartupCommandRunner.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/StartupCommandRunner.cs @@ -18,6 +18,7 @@ using System.Diagnostics.CodeAnalysis; +using Eppie.CLI.Exceptions; using Eppie.CLI.Menu; using Eppie.CLI.Tools; @@ -31,14 +32,12 @@ internal sealed class StartupCommandRunner( ILogger logger, RawCommandLineArguments commandLineArguments, IOptions launchOptions, - IApplicationOutputWriter outputWriter, IApplicationUnlocker applicationUnlocker, IApplicationMenu applicationMenu) : IStartupCommandRunner { private readonly ILogger _logger = logger; private readonly RawCommandLineArguments _commandLineArguments = commandLineArguments; private readonly ApplicationLaunchOptions _launchOptions = launchOptions.Value; - private readonly IApplicationOutputWriter _outputWriter = outputWriter; private readonly IApplicationUnlocker _applicationUnlocker = applicationUnlocker; private readonly IApplicationMenu _applicationMenu = applicationMenu; @@ -59,23 +58,14 @@ public async Task TryRunAsync(CancellationToken cancellationToken) { if (_launchOptions.NonInteractive && !_launchOptions.UnlockPasswordFromStandardInput) { - WriteUnlockPasswordFromStandardInputHint(commandName); - return true; + throw new ApplicationCommandException(new StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput(commandName)); } - if (!await _applicationUnlocker.UnlockAsync(cancellationToken, readPasswordFromStandardInput: _launchOptions.NonInteractive).ConfigureAwait(false)) - { - return true; - } + await _applicationUnlocker.UnlockAsync(cancellationToken, readPasswordFromStandardInput: _launchOptions.NonInteractive).ConfigureAwait(false); } await _applicationMenu.InvokeCommandAsync(startupCommandArguments).ConfigureAwait(false); return true; } - - private void WriteUnlockPasswordFromStandardInputHint(string commandName) - { - _outputWriter.Write(new StartupCommandRequiresUnlockPasswordFromStandardInputWarningOutput(commandName)); - } } } diff --git a/src/Eppie.CLI/Eppie.CLI/Services/TextApplicationOutputWriter.cs b/src/Eppie.CLI/Eppie.CLI/Services/TextApplicationOutputWriter.cs index 740223fa..60545a6f 100644 --- a/src/Eppie.CLI/Eppie.CLI/Services/TextApplicationOutputWriter.cs +++ b/src/Eppie.CLI/Eppie.CLI/Services/TextApplicationOutputWriter.cs @@ -64,7 +64,7 @@ public void Write(ApplicationOutput output) Console.WriteLine(_resourceLoader.Strings.AppRestored); return; case AuthorizationCanceledOutput: - Console.WriteLine(_resourceLoader.Strings.AuthorizationCanceled); + WriteError(_resourceLoader.Strings.AuthorizationCanceled); return; case AuthorizationToServiceOutput authorizationToServiceOutput: Console.WriteLine(_resourceLoader.Strings.GetAuthorizationToServiceText(authorizationToServiceOutput.ServiceName)); @@ -73,7 +73,8 @@ public void Write(ApplicationOutput output) Console.WriteLine(_resourceLoader.Strings.AuthorizationCompleted); return; case ProtonHumanVerificationRequiredOutput humanVerificationRequiredOutput: - Console.WriteLine(_resourceLoader.Strings.GetProtonHumanVerificationRequiredText(humanVerificationRequiredOutput.VerificationUri)); + Console.WriteLine(_resourceLoader.Strings.GetProtonHumanVerificationRequiredText(humanVerificationRequiredOutput.VerificationUri, + ProtonHumanVerificationRequiredOutput.HelpUri)); return; case InvalidPasswordWarningOutput: WriteWarning(_resourceLoader.Strings.InvalidPassword); @@ -109,6 +110,12 @@ public void Write(ApplicationOutput output) WriteError(_resourceLoader.Strings.GetStructuredStandardInputMissingPropertyError(structuredInputMissingPropertyOutput.CommandName, structuredInputMissingPropertyOutput.PropertyName)); return; + case InteractiveInputNotSupportedErrorOutput: + WriteError(_resourceLoader.Strings.InteractiveInputNotSupported); + return; + case StandardInputEndedErrorOutput: + WriteError(_resourceLoader.Strings.StandardInputEnded); + return; case ImpossibleInitializationErrorOutput: WriteError(_resourceLoader.Strings.ImpossibleInitialization); return;