using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; namespace DodoSSH.Client.App.Tests; /// /// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync. /// /// /// Runs with no Avalonia, no browser and no identity provider, because the view models are plain /// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got /// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test /// rather than by remembering to click through them. /// public sealed class ShellFlowTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; /// /// Far below the shipped profile, for the same reason as everywhere else: these tests are about the /// state machine, not about how expensive the passphrase is to attack. /// private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeVaultServer server = new(); /// /// The real factory would need a reachable sshd, which DodoSSH.Client.Ssh.Tests covers against /// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes /// the connect path reachable. /// private readonly FakeSshConnectionFactory ssh = new(); private int signInAttempts; private string directory = null!; private ClientPaths paths = null!; private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private MainWindowViewModel shell = null!; /// public ValueTask InitializeAsync() { // A real directory and a real SQLite file, because the production path is what StartAsync runs and // an in-memory database would skip the migration that creates the file. directory = Path.Combine(Path.GetTempPath(), $"dodossh-shell-{Guid.CreateVersion7():N}"); paths = new ClientPaths(directory); caches = ClientCacheFactory.ForFile(paths.CacheFile); var knownHosts = new InMemoryKnownHostStore(); // In-memory assets rather than the application's Avalonia-resource provider, which reads the // resource system at construction and needs an initialised toolkit. This is what // ITerminalAssetProvider is for; nothing in this suite renders anything. // // The page carries the same two placeholders the real one does, because FakeRenderer attaches by // reading them back out of the served page rather than by being handed the token. workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider( new Dictionary(StringComparer.Ordinal) { ["/terminal"] = new( "text/html; charset=utf-8", System.Text.Encoding.UTF8.GetBytes( $"
")), }), ssh, TimeProvider.System); // Started, as the application does immediately after composing it. Without the accept loop the // page is never served, so nothing could attach a renderer. workspace.Start(); shell = new MainWindowViewModel( paths, caches, workspace, knownHosts, SignInAsync, TimeProvider.System, CheapProfile); return ValueTask.CompletedTask; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); await workspace.DisposeAsync(); caches.Dispose(); if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } [Fact] public async Task AFreshMachine_AsksForAServer() { await shell.StartAsync(Token); shell.State.ShouldBe(ShellState.NeedsServer); shell.IsNeedingServer.ShouldBeTrue(); shell.IsOnline.ShouldBeFalse(); // The migration ran, so the file exists before anyone has signed in to anything. File.Exists(paths.CacheFile).ShouldBeTrue(); } [Fact] public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase() { await shell.StartAsync(Token); await shell.SignInCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsEnrollment); shell.IsOnline.ShouldBeTrue(); shell.AccountName.ShouldBe("Alice Example"); } [Fact] public async Task AnUnreachableServer_ReportsAndStaysPut() { server.SignInFailure = new HttpRequestException("No such host is known."); await shell.StartAsync(Token); await shell.SignInCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsServer); shell.StatusMessage.ShouldContain("No such host"); shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled"); } [Fact] public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork() { await shell.StartAsync(Token); shell.ServerUrl = "not a url"; await shell.SignInCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsServer); shell.IsOnline.ShouldBeFalse(); } /// /// Separate from the case above because it is not caught by the same check. Uri.TryCreate /// accepts this happily as an absolute URI whose scheme is "localhost" and whose host is /// empty, so without an explicit scheme check the mistake surfaces much later as something that reads /// like a network fault. /// [Fact] public async Task AServerUrlWithNoScheme_SaysSoRatherThanFailingLater() { await shell.StartAsync(Token); shell.ServerUrl = "localhost:5233"; await shell.SignInCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsServer); shell.IsOnline.ShouldBeFalse(); shell.StatusMessage.ShouldContain("http://"); signInAttempts.ShouldBe(0, "a malformed URL must not open a browser"); } /// /// The shipped default is a value a user is invited to accept unread, so it is worth one assertion. /// It was https://localhost:7217 — the API's second launch profile — while the README, the /// API's appsettings and a plain dotnet run all use HTTP on 5233, and pointing an HTTPS client /// at a plaintext port reports a TLS failure that reads like a certificate problem. Nothing failed /// except the first thing a new user does. /// [Fact] public void TheDefaultServerUrl_IsTheAddressTheApiActuallyServes() { shell.ServerUrl.ShouldBe("http://localhost:5233"); } [Theory] [InlineData("short", "short")] [InlineData("a sufficiently long passphrase", "a different one")] public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation) { await SignedInAsync(); shell.Passphrase = entered; shell.ConfirmPassphrase = confirmation; await shell.EnrollCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsEnrollment); server.EnrollmentCount.ShouldBe(0); } [Fact] public async Task TheRecoveryCodeScreen_CannotBeSkipped() { // The only moment the code exists. Losing it along with the passphrase means the vault is // unrecoverable and there is no server-side reset, so this is the one screen that has to insist. await EnrolledAsync(); shell.State.ShouldBe(ShellState.ShowingRecoveryCode); shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace(); // Trying to continue without confirming gets nowhere. shell.ConfirmRecoveryCodeCommand.Execute(null); shell.State.ShouldBe(ShellState.ShowingRecoveryCode); shell.RecoveryCode.ShouldNotBeNull(); shell.RecoveryCodeWrittenDown = true; shell.ConfirmRecoveryCodeCommand.Execute(null); shell.State.ShouldBe(ShellState.Locked); // And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view // model for the rest of the session would undo that. shell.RecoveryCode.ShouldBeNull(); } [Fact] public async Task AWrongPassphrase_KeepsTheVaultLocked() { await ReadyToUnlockAsync(); shell.Passphrase = "not the passphrase"; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Locked); shell.Vault.ShouldBeNull(); shell.StatusMessage.ShouldContain("did not open"); } [Fact] public async Task UnlockingOpensTheVault() { await UnlockedAsync(); shell.State.ShouldBe(ShellState.Unlocked); shell.Vault.ShouldNotBeNull(); shell.Vault.VaultName.ShouldBe("Personal"); // Cleared once used, so it is not sitting in a bound property for the rest of the session. shell.Passphrase.ShouldBeEmpty(); } [Fact] public async Task ARestartUnlocksWithNoNetworkAtAll() { // The property the whole storage layer exists for, from the shell's point of view. The second // shell is given a sign-in delegate that fails if called. await EnrolledAndConfirmedAsync(); await shell.LockCommand.ExecuteAsync(null); var offline = new MainWindowViewModel( paths, caches, workspace, new InMemoryKnownHostStore(), (_, _) => throw new InvalidOperationException("The shell went to the network to unlock."), TimeProvider.System, CheapProfile); await using var _ = offline.ConfigureAwait(false); await offline.StartAsync(Token); offline.State.ShouldBe(ShellState.Locked); offline.AccountName.ShouldBe("Alice Example"); offline.IsOnline.ShouldBeFalse(); offline.Passphrase = Passphrase; await offline.UnlockCommand.ExecuteAsync(null); offline.State.ShouldBe(ShellState.Unlocked); offline.Vault.ShouldNotBeNull(); } [Fact] public async Task AddingAHost_ShowsItImmediatelyAndQueuesIt() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue(); vault.EditorLabel = "prod-db"; vault.EditorHostname = "db.internal"; vault.EditorUsername = "deploy"; vault.EditorPort = 2222; await vault.SaveHostCommand.ExecuteAsync(null); vault.IsEditing.ShouldBeFalse(); var row = vault.Hosts.ShouldHaveSingleItem(); row.Label.ShouldBe("prod-db"); row.Address.ShouldBe("deploy@db.internal:2222"); row.HasUnsyncedChanges.ShouldBeTrue(); row.Badge.ShouldBe("not synced"); vault.PendingChanges.ShouldBe(1); server.LiveRowCount.ShouldBe(0, "nothing should have been pushed yet"); } /// /// /// The page's own term.focus() focuses the textarea inside the document, which does nothing /// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a /// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked /// first. The view hands the control focus when this fires; see NativeKeyboardFocus for why an /// ordinary Focus() call is enough in that direction and not in the other. /// /// /// What this covers is the plumbing that carries the fix: that the raise is on the success path and /// happens once per session, and that the shell forwards it. Deleting the raise outright is already a /// build error — the event would be unused, and warnings are errors — but moving it, which is the /// likelier mistake, is not. It does not cover the focus call itself: that needs a native /// window, and headless Avalonia has none, which is exactly why this class of defect has escaped /// tests here before. Measured separately in a harness; see docs/platform-flags.md. /// /// [Fact] public async Task ConnectingAsksTheViewToFocusTheTerminal() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); var requests = 0; shell.TerminalSessionOpened += (_, _) => requests++; await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldContain("Connected", Case.Insensitive); requests.ShouldBe(1); // Again, on a second session. This is why it is an event and not a bound flag: a boolean that was // already true would not move focus to the terminal the user just opened. await vault.ConnectCommand.ExecuteAsync(null); requests.ShouldBe(2); } /// /// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own /// buttons, and taking it into a terminal that has no session would strand the decision. /// [Fact] public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Failure = new SshHostKeyUnknownException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown")); var requests = 0; shell.TerminalSessionOpened += (_, _) => requests++; await vault.ConnectCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeTrue(); requests.ShouldBe(0); } /// /// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile /// and pass every other test, while leaving a discarded vault able to move focus in a locked window. /// [Fact] public async Task LockingStopsTheShellForwardingFocusRequests() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); var requests = 0; shell.TerminalSessionOpened += (_, _) => requests++; await vault.ConnectCommand.ExecuteAsync(null); requests.ShouldBe(1); await shell.LockCommand.ExecuteAsync(null); shell.Vault.ShouldBeNull(); // The discarded vault is detached, so even a late raise from it reaches nobody. await vault.ConnectCommand.ExecuteAsync(null); requests.ShouldBe(1); } [Fact] public async Task AnInvalidHost_IsRefusedWithAReason() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.EditorLabel = " "; vault.EditorHostname = "db.internal"; await vault.SaveHostCommand.ExecuteAsync(null); vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it"); vault.Hosts.ShouldBeEmpty(); vault.Status.ShouldContain("needs a name"); } [Fact] public async Task SyncingSendsTheQueueAndClearsIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await vault.SyncCommand.ExecuteAsync(null); server.LiveRowCount.ShouldBe(1); vault.PendingChanges.ShouldBe(0); vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse(); vault.Status.ShouldContain("Synchronised"); } [Fact] public async Task EditingAHostRoundTripsThroughTheEditor() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await vault.SyncCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorLabel.ShouldBe("prod-db"); vault.EditorHostname.ShouldBe("db.internal"); vault.EditorNotes = "rotate quarterly"; await vault.SaveHostCommand.ExecuteAsync(null); await vault.SyncCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly"); } [Fact] public async Task DeletingAHostRemovesItLocallyBeforeTheServerAgrees() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await vault.SyncCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts[0]; await vault.DeleteHostCommand.ExecuteAsync(null); vault.Hosts.ShouldBeEmpty(); server.LiveRowCount.ShouldBe(1, "the tombstone has not been pushed yet"); await vault.SyncCommand.ExecuteAsync(null); server.LiveRowCount.ShouldBe(0); } [Fact] public async Task SyncingWhileOffline_QueuesRatherThanFailing() { await EnrolledAndConfirmedAsync(); await shell.LockCommand.ExecuteAsync(null); // Locking does not drop the connection, so take a fresh shell that never signed in. var offline = new MainWindowViewModel( paths, caches, workspace, new InMemoryKnownHostStore(), (_, _) => throw new InvalidOperationException("unreachable"), TimeProvider.System, CheapProfile); await using var _ = offline.ConfigureAwait(false); await offline.StartAsync(Token); offline.Passphrase = Passphrase; await offline.UnlockCommand.ExecuteAsync(null); var vault = offline.Vault!; await AddHostAsync(vault, "offline-host"); await vault.SyncCommand.ExecuteAsync(null); vault.Status.ShouldContain("Offline"); vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded"); server.PushCount.ShouldBe(0); } [Fact] public async Task LockingForgetsTheVault() { await UnlockedAsync(); await shell.LockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Locked); shell.Vault.ShouldBeNull(); // And unlocking again works, so locking released rather than corrupted anything. shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked); } // ---- Helpers ---- private static CancellationToken Token => TestContext.Current.CancellationToken; private Task SignInAsync(Uri serverUrl, CancellationToken cancellationToken) { // Counted so a test can assert that a rejected URL never got this far. Reaching here means a // browser would have opened in the real application. signInAttempts++; return server.SignInFailure is { } failure ? Task.FromException(failure) : Task.FromResult(server); } private async Task SignedInAsync() { await shell.StartAsync(Token); await shell.SignInCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsEnrollment); } private async Task EnrolledAsync() { await SignedInAsync(); shell.Passphrase = Passphrase; shell.ConfirmPassphrase = Passphrase; await shell.EnrollCommand.ExecuteAsync(null); } private async Task EnrolledAndConfirmedAsync() { await EnrolledAsync(); shell.RecoveryCodeWrittenDown = true; shell.ConfirmRecoveryCodeCommand.Execute(null); shell.State.ShouldBe(ShellState.Locked); } private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync(); private async Task UnlockedAsync() { await EnrolledAndConfirmedAsync(); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); } private static async Task AddHostAsync(VaultViewModel vault, string label) { vault.NewHostCommand.Execute(null); vault.EditorLabel = label; vault.EditorHostname = "db.internal"; vault.EditorUsername = "deploy"; await vault.SaveHostCommand.ExecuteAsync(null); } /// An unlocked vault with one selected host and a renderer attached. private async Task ReadyToConnectAsync() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; return vault; } }