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. // // The renderer timeout is cut right down for the same reason: the tests that want a renderer attach // one themselves, so a connect in a test that does not would wait the shipped fifteen seconds out // in full, and that is fifteen seconds of a suite sitting still. A second rather than milliseconds // because this bounds FakeRenderer's own wait too — an in-process loopback handshake that has // already returned, so the margin is enormous, but not one worth making a loaded machine race for. workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider( new Dictionary(StringComparer.Ordinal) { ["/terminal"] = new( "text/html; charset=utf-8", System.Text.Encoding.UTF8.GetBytes( $"
")), }), ssh, TimeProvider.System, new TerminalWorkspaceOptions { RendererTimeout = TimeSpan.FromSeconds(1) }); // 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(); } /// /// This used to assert the opposite of its last two lines — that a save queued the change and pushed /// nothing until Sync was pressed. Saving now pushes, so the assertion had to move rather than be /// deleted: the local-first guarantee it was really protecting is that the list updates without a /// server, and that is still covered by the offline test below. /// [Fact] public async Task AddingAHost_ShowsItImmediatelyAndPushesIt() { 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.ShouldBeFalse("saving pushes, so nothing should still be pending"); row.Badge.ShouldBeEmpty(); vault.PendingChanges.ShouldBe(0); server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync"); } [Fact] public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo() { await UnlockedAsync(); var vault = shell.Vault!; vault.Status = "Reading something the user cares about."; await vault.AutoSyncAsync(Token); vault.Status.ShouldBe( "Reading something the user cares about.", "a background pass with no changes must not repaint the status line"); vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag"); } /// /// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a /// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending /// change left and the count was unchanged whether the guard existed or not. It passed with the guard /// deleted, which is the only reason it was noticed. /// [Fact] public async Task AnAutomaticPass_YieldsWhileACommandIsRunning() { await UnlockedAsync(); var vault = shell.Vault!; server.SyncFailure = new HttpRequestException("The server is having a bad day."); await AddHostAsync(vault, "prod-db"); server.SyncFailure = null; vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything"); var pushesBefore = server.PushCount; // Standing in for a command in flight. A pass that pushed here would be competing with whatever // the user is doing for the same session and the same cache. vault.IsBusy = true; await vault.AutoSyncAsync(Token); server.PushCount.ShouldBe(pushesBefore, "the pass should have been skipped, not queued"); } /// /// The behaviour a background loop lives or dies by. A pass runs every minute; one that reported a /// transient server error would replace whatever the user was reading, once a minute, indefinitely. /// [Fact] public async Task AnAutomaticPassThatFails_LeavesTheStatusAlone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.Status = "Reading something the user cares about."; server.SyncFailure = new HttpRequestException("The server is having a bad day."); await vault.AutoSyncAsync(Token); vault.Status.ShouldBe("Reading something the user cares about."); vault.IsBusy.ShouldBeFalse(); // And pressing Sync still reports the real reason, so the failure is quiet rather than hidden. await vault.SyncCommand.ExecuteAsync(null); vault.Status.ShouldContain("bad day"); } /// /// /// 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"); } /// /// This is what earns the right to let a background pass fail silently. The automatic push after a save /// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would /// mean "lost". /// [Fact] public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync() { await UnlockedAsync(); var vault = shell.Vault!; server.SyncFailure = new HttpRequestException("The server is having a bad day."); await AddHostAsync(vault, "prod-db"); server.LiveRowCount.ShouldBe(0, "the automatic push should have failed"); vault.PendingChanges.ShouldBe(1, "and the change should still be queued"); vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue(); server.SyncFailure = null; 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 DeletingAHost_RemovesItLocallyAndPushesTheTombstone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; await vault.DeleteHostCommand.ExecuteAsync(null); vault.Hosts.ShouldBeEmpty(); // Pushed without a second action. A tombstone that sat in the outbox would let the item come back // on a machine that synced in the meantime. server.LiveRowCount.ShouldBe(0); vault.PendingChanges.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); } /// /// The one connect test that deliberately attaches no : the listener is up /// and nothing ever connects to it, which from the view model's side is indistinguishable from a /// WebView2 that failed to initialise on a user's machine. The interesting assertion is the second /// one: while the wait was unbounded this hung with the busy flag set, so the window stayed disabled /// and said "Connecting…" for the rest of the session. /// [Fact] public async Task ConnectingWithNoRenderer_ExplainsItselfAndReleasesTheWindow() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; await vault.ConnectCommand.ExecuteAsync(null); // Naming the runtime is the whole point: a bare "The operation has timed out" sends someone // looking at their network or their host. vault.Status.ShouldContain("WebView2"); vault.IsBusy.ShouldBeFalse("a connect that gave up must not leave the window disabled"); } [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); } /// /// The deliberate half of what Lock does: the vault closes, the shells do not. /// /// /// /// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a /// person does when they walk away from the machine, and that is exactly when a long job is most /// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock /// would do it unattended. MainWindowViewModel.LockAsync carries the full argument. /// /// /// The disclosure is asserted along with the behaviour, because the two are the same decision. A /// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while /// authenticated SSH channels stay open is only defensible if it says so. /// /// [Fact] public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen() { await UnlockedAsync(); // Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can // satisfy the renderer gate — but what is under test is what Lock does to a session that exists, // not how it came to exist, and going through the gate would only add a way for this to fail. await workspace.OpenSessionAsync( new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")), TerminalSize.Default, Token); workspace.LiveSessionCount.ShouldBe(1); await shell.LockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Locked); shell.Vault.ShouldBeNull("the vault's keys are gone"); workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running"); shell.HasLiveSessions.ShouldBeTrue(); shell.LiveSessionCount.ShouldBe(1); shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running."); // And it survives the unlock too, so the session outlives the whole cycle rather than merely // outliving the disposal. shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked); workspace.LiveSessionCount.ShouldBe(1); } [Fact] public async Task LockingWithNoOpenShells_DisclosesNothing() { await UnlockedAsync(); await shell.LockCommand.ExecuteAsync(null); shell.LiveSessionCount.ShouldBe(0); shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing"); } // ---- SSH keys ---- [Fact] public async Task AddingAKey_ShowsItImmediatelyAndPushesIt() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeTrue(); vault.KeyEditorLabel = "deploy"; vault.KeyEditorPrivateKey = PrivateKey("MATERIAL"); vault.KeyEditorPassphrase = "hunter2"; await vault.SaveKeyCommand.ExecuteAsync(null); vault.IsEditingKey.ShouldBeFalse(); var row = vault.Keys.ShouldHaveSingleItem(); row.Label.ShouldBe("deploy"); row.Description.ShouldBe("passphrase · no public half"); row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending"); vault.PendingChanges.ShouldBe(0); server.LiveRowCount.ShouldBe(1, "a saved key should reach the server without pressing Sync"); // And the host list is untouched, so the two lists are genuinely separate. vault.Hosts.ShouldBeEmpty(); } [Fact] public async Task AddingAKey_BindsItToNothing() { // Storing a key must not change how any host authenticates. The failure this rules out is a key // nobody chose being offered to a host — a credential leaving the vault by accident. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddKeyAsync(vault, "deploy"); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull(); vault.Hosts[0].Authentication.ShouldBe("password"); await vault.SyncCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull(); } [Fact] public async Task EditingAKey_RoundTripsThroughTheEditorIncludingTheMaterial() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await vault.SyncCommand.ExecuteAsync(null); vault.SelectedKey = vault.Keys[0]; vault.EditSelectedKeyCommand.Execute(null); // The material has to come back into the editor. The codec has no partial update, so a save // re-encodes every field — an editor that loaded a blank private key would erase it. vault.KeyEditorLabel.ShouldBe("deploy"); vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("MATERIAL")); vault.KeyEditorPassphrase.ShouldBe("hunter2"); vault.KeyEditorNotes = "rotate quarterly"; await vault.SaveKeyCommand.ExecuteAsync(null); await vault.SyncCommand.ExecuteAsync(null); var saved = vault.Keys.ShouldHaveSingleItem().Key; saved.Notes.ShouldBe("rotate quarterly"); saved.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL")); saved.Passphrase.ShouldBe("hunter2"); } [Fact] public async Task CancellingTheKeyEditor_LeavesNoMaterialBehindInIt() { // The editor holds a private key in a bound property for as long as it is open. It cannot be wiped // — see SshKeySecret — but it can stop being referenced, and an abandoned editor that kept the key // would hand it to whatever opened next. await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorLabel = "deploy"; vault.KeyEditorPrivateKey = PrivateKey("ABANDONED"); vault.KeyEditorPassphrase = "hunter2"; vault.CancelKeyEditCommand.Execute(null); vault.IsEditingKey.ShouldBeFalse(); vault.KeyEditorPrivateKey.ShouldBeEmpty(); vault.KeyEditorPassphrase.ShouldBeEmpty(); vault.KeyEditorLabel.ShouldBeEmpty(); vault.Keys.ShouldBeEmpty(); } [Fact] public async Task APublicKeyPastedIntoThePrivateField_NamesTheActualMistake() { // ssh-keygen writes two files whose names differ by four characters. The message has to say which // one to pick, because the alternative is an authentication failure at connect time that says // nothing about the file. await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorLabel = "deploy"; vault.KeyEditorPrivateKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop"; await vault.SaveKeyCommand.ExecuteAsync(null); vault.Status.ShouldContain(".pub"); vault.Keys.ShouldBeEmpty(); vault.IsEditingKey.ShouldBeTrue("the editor stays open so the paste can be corrected"); } [Fact] public async Task DeletingAKey_RemovesItLocallyAndPushesTheTombstone() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); vault.SelectedKey = vault.Keys[0]; await vault.DeleteKeyCommand.ExecuteAsync(null); vault.Keys.ShouldBeEmpty(); server.LiveRowCount.ShouldBe(0); vault.PendingChanges.ShouldBe(0); } /// /// A layout invariant expressed as a state one, because it is the only form of it this repository can /// check: nothing here loads a .axaml, and both editors are Auto rows in the same /// 340-pixel column whose combined height exceeds the column at the window's minimum size. Two open /// editors put the lower one's buttons past the bottom edge — the same failure this window shipped once /// already, with the setup screens sliced and unclickable. /// [Fact] public async Task OnlyOneEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE"); vault.NewHostCommand.Execute(null); vault.IsEditing.ShouldBeFalse("the host editor must not open over the key editor"); vault.IsEditingKey.ShouldBeTrue(); vault.Status.ShouldContain("SSH key"); // The refusal is worth nothing if it costs the paste. vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE")); // And it is a refusal, not a lockout. vault.CancelKeyEditCommand.Execute(null); vault.NewHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue(); // Symmetrically, with the host editor holding the column. vault.EditorLabel = "half-typed"; vault.NewKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeFalse(); vault.EditorLabel.ShouldBe("half-typed"); vault.Status.ShouldContain("host"); } [Fact] public async Task EditingAnExistingItem_IsRefusedByTheOtherEditorToo() { // The Edit commands are a second door into the same column, and guarding only the Add ones would // leave it wide open. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddKeyAsync(vault, "deploy"); vault.SelectedHost = vault.Hosts[0]; vault.SelectedKey = vault.Keys[0]; vault.NewKeyCommand.Execute(null); vault.EditSelectedHostCommand.Execute(null); vault.IsEditing.ShouldBeFalse(); vault.CancelKeyEditCommand.Execute(null); vault.NewHostCommand.Execute(null); vault.EditSelectedKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeFalse(); } // ---- Binding a key to a host ---- [Fact] public async Task BindingAKeyToAHost_RoundTripsThroughTheEditorAndTheVault() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); var keyId = vault.Keys[0].EntityId; await BindKeyAsync(vault, vault.Hosts[0], keyId); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId); vault.Hosts[0].Authentication.ShouldBe("key"); // Through the server and back, which is what makes it a property of the host rather than of this // machine — the whole reason it is a payload field and not a local preference. await vault.SyncCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId); // And it is offered back correctly when the editor reopens, including as the current selection. vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedKey.ShouldNotBeNull().EntityId.ShouldBe(keyId); vault.EditorKeyChoices[0].EntityId.ShouldBeNull("the password entry stays first"); } [Fact] public async Task AHostWithNoKey_UsesThePassword() { var vault = await ReadyToConnectAsync(); // A key exists in the vault and is even selected in the key list. An unbound host must still use // the password: the list selection is for editing keys, not for deciding authentication. await AddKeyAsync(vault, "deploy"); vault.SelectedKey = vault.Keys[0]; vault.ConnectPassword = "typed-in"; await ConnectWithRendererAsync(vault); ssh.Requests.ShouldHaveSingleItem().Credential .ShouldBeOfType() .Password.ShouldBe("typed-in"); } [Fact] public async Task AHostBoundToAKey_HandsTheSshStackTheKeyAndItsPassphrase() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); vault.ConnectPassword = "should-not-be-used"; await ConnectWithRendererAsync(vault); var credential = ssh.Requests.ShouldHaveSingleItem().Credential .ShouldBeOfType(); System.Text.Encoding.UTF8.GetString(credential.PrivateKeyPem) .ShouldBe(PrivateKey("MATERIAL")); credential.Passphrase.ShouldBe("hunter2"); } [Fact] public async Task AKeyWithABlankPassphraseBox_IsAKeyWithNoPassphrase() { // Blank and absent are one state, from the editor all the way to the credential. The list has to say // so too, because "passphrase" against a key that has none sends someone hunting for one they never // set — and SSH.NET will not correct them: it ignores a passphrase on an unprotected key rather than // refusing it. See docs/platform-flags.md. var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy", passphrase: string.Empty); vault.Keys.ShouldHaveSingleItem().Description.ShouldStartWith("no passphrase"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); await ConnectWithRendererAsync(vault); ssh.Requests.ShouldHaveSingleItem().Credential .ShouldBeOfType() .Passphrase.ShouldBeNull(); } [Fact] public async Task AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword() { // A key deleted on another machine is ordinary, and this is what it must not cause: a host somebody // deliberately set up for key-only access quietly starting to offer a password instead. var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); vault.SelectedKey = vault.Keys[0]; await vault.DeleteKeyCommand.ExecuteAsync(null); vault.Keys.ShouldBeEmpty(); vault.SelectedHost = vault.Hosts[0]; vault.ConnectPassword = "must-not-be-sent"; await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all"); vault.Status.ShouldContain("not in this vault"); } [Fact] public async Task EditingAHostWhoseKeyHasBeenDeleted_DoesNotQuietlyUnbindIt() { // The same failure one step removed, and the subtler one. Someone opens the host to change its port; // if the picker had silently fallen back to "no key", saving would convert it to password // authentication and nothing would ever have said so. var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); var keyId = vault.Keys[0].EntityId; await BindKeyAsync(vault, vault.Hosts[0], keyId); vault.SelectedKey = vault.Keys[0]; await vault.DeleteKeyCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); // The binding is still there, still selected, and says what is wrong with it. var selected = vault.EditorSelectedKey.ShouldNotBeNull(); selected.EntityId.ShouldBe(keyId); selected.Label.ShouldContain("no longer here"); vault.EditorPort = 2244; await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244); vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId, "an unrelated edit must not drop the binding"); } [Fact] public async Task RemovingABinding_PutsTheHostBackOnAPassword() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedKey = vault.EditorKeyChoices.Single(choice => choice.EntityId is null); await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull(); vault.Hosts[0].Authentication.ShouldBe("password"); vault.ConnectPassword = "typed-in"; await ConnectWithRendererAsync(vault); ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType(); } // ---- Helpers ---- private static CancellationToken Token => TestContext.Current.CancellationToken; /// Armoured material of a plausible shape, and deliberately not a usable key. private static string PrivateKey(string body) => $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n"; 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); } private static async Task AddKeyAsync( VaultViewModel vault, string label, string material = "MATERIAL", string passphrase = "hunter2") { vault.NewKeyCommand.Execute(null); vault.KeyEditorLabel = label; vault.KeyEditorPrivateKey = PrivateKey(material); vault.KeyEditorPassphrase = passphrase; await vault.SaveKeyCommand.ExecuteAsync(null); } /// Points a host at a key through the editor, the way a user would. private static async Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) { vault.SelectedHost = host; vault.EditSelectedHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue("the host editor has to be open for the picker to be populated"); vault.EditorSelectedKey = vault.EditorKeyChoices.Single(choice => choice.EntityId == keyId); await vault.SaveHostCommand.ExecuteAsync(null); } /// Connects with a renderer attached, which the data plane requires before a session opens. private async Task ConnectWithRendererAsync(VaultViewModel vault) { await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldContain("Connected", Case.Insensitive); } /// 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; } }