using System.Globalization; using DodoSSH.Client.Auth; using DodoSSH.Client.Domain; using DodoSSH.Client.Import; using DodoSSH.Client.ObjectStore; using DodoSSH.Client.Session; // FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace; // see the csproj for why it is shared rather than reimplemented. using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Sync; 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"; /// Everything this shell has copied, newest last. private readonly List clipboard = []; /// /// 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; /// How many times a shell has tried to resume a remembered sign-in, and with what. /// /// Counted rather than merely allowed, because the interesting assertions about resuming are about how /// often it happens: once per launch when it works, and never again once the provider has refused. /// private int resumeAttempts; private string? resumedWith; /// When set, resuming throws — how a revoked or rotated-away token is exercised. private Exception? resumeFailure; private string directory = null!; private ClientPaths paths = null!; private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private VaultKnownHostStore knownHosts = null!; /// /// A fake rather than the real TPM-backed store, and not for speed: the real one prompts for a Windows /// consent dialog on every save and every load, so a suite using it would block forever waiting for /// somebody to enter a PIN. What the shell has to get right is which buttons appear and what happens when /// one is pressed, and that is exactly what a fake keystore can answer. /// private FakeDeviceKeyStore deviceKeys = 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); // The real store, not a stand-in. It is the one the application composes, its lifecycle is this // shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault // this suite already has, so substituting one would only stop the wiring being tested. knownHosts = new VaultKnownHostStore(); deviceKeys = new FakeDeviceKeyStore(); // 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, deviceKeys, SignInAsync, TimeProvider.System, ssh, CheapProfile, ResumeAsync, // ◆ A clipboard, where this fixture used to pass none. Both heads wire one now — the phone's // was simply never passed, which made every copy on that head answer "this machine has no // clipboard" on a device that plainly has one. A fixture without one modelled the bug rather // than the product. The branch for a machine that really has none is still covered, by a shell // built without one where it is the thing under test. copyToClipboard: text => { clipboard.Add(text); return Task.CompletedTask; }, // Inline, because this suite has no window and therefore no dispatcher to drain — the same // answer TransferQueueingTests reached, and for the reason its own remark gives: reaching // Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched // it first. Running the action where it was raised takes the thread out of the question, and // every phase this suite reports is raised on the thread doing the asserting anyway. post: action => action()); 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 void TheTerminalFontSize_StartsAtTheSizeTheRendererDrawsAt() { // The page creates panes at its own constant until it is told otherwise. A different number here // would show as the terminal resizing itself on every launch, in front of the user. shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize); } /// /// The cap is what stops "larger" arriving at a terminal too narrow to hold a prompt — this resizes the /// grid rather than magnifying it, so every step up is columns taken away from the remote. Asserted /// through the command rather than the clamp so the disabled state is covered with it: a button that /// keeps accepting presses and does nothing reads as the application having stopped responding. /// [Fact] public void EnlargingPastTheCap_StopsAndSaysSo() { for (var i = 0; i < 100; i++) { shell.EnlargeTerminalFontCommand.Execute(null); } shell.TerminalFontSize.ShouldBe(ClientSettings.MaximumTerminalFontSize); shell.CanEnlargeTerminalFont.ShouldBeFalse(); shell.CanShrinkTerminalFont.ShouldBeTrue(); } [Fact] public void ShrinkingPastTheFloor_StopsAndSaysSo() { for (var i = 0; i < 100; i++) { shell.ShrinkTerminalFontCommand.Execute(null); } shell.TerminalFontSize.ShouldBe(ClientSettings.MinimumTerminalFontSize); shell.CanShrinkTerminalFont.ShouldBeFalse(); } [Fact] public void ResettingTheTerminalFont_GoesBackToTheDefault() { shell.EnlargeTerminalFontCommand.Execute(null); shell.EnlargeTerminalFontCommand.Execute(null); shell.ResetTerminalFontCommand.Execute(null); shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize); } /// /// The reason the setting is a file beside the cache rather than a row inside it: this has to be /// readable on a launch that never unlocks anything, which is every launch up to the passphrase. A /// second shell over the same profile directory is exactly that launch. /// [Fact] public async Task ASizeChosenOnce_IsThereOnTheNextLaunch() { shell.EnlargeTerminalFontCommand.Execute(null); shell.EnlargeTerminalFontCommand.Execute(null); var chosen = shell.TerminalFontSize; chosen.ShouldBe(ClientSettings.DefaultTerminalFontSize + 2); var relaunched = new MainWindowViewModel( paths, caches, workspace, knownHosts, deviceKeys, SignInAsync, TimeProvider.System, ssh, CheapProfile, ResumeAsync); await using (relaunched.ConfigureAwait(false)) { relaunched.TerminalFontSize.ShouldBe(chosen); } } [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 — it is what the button under it /// will actually contact — so it is worth one assertion. It has twice been an address nothing was /// listening on: https://localhost:7217, the API's second launch profile, and then the first /// profile's http://localhost:5233 in a release build, which is a machine an installed /// application is not running. /// /// /// The address is written out rather than compared against the constant itself. Asserting a constant /// against itself would pass however it were edited, and the whole point of this test is that no /// build ships a developer's loopback address. /// /// [Fact] public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild() { shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud"); } [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 VaultKnownHostStore(), new UnavailableDeviceKeyStore(), (_, _) => throw new InvalidOperationException("The shell went to the network to unlock."), TimeProvider.System, ssh, 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 whole point of the push channel: a pass that did not wait for the minute. /// /// /// /// The timing is what makes this an assertion rather than a hope. The background timer is a full /// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been /// caused by the notice — there is no interval at which the timer could have produced it. /// /// /// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault /// this session can reach, so the loop reads the notice as "there is something to fetch" and never /// as "fetch this one". A test that seeded a real id would imply a targeting this does not do. /// /// [Fact] public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer() { await UnlockedAsync(); // The unlock starts the loop, whose first act is a pass; waited out so the count below is a // baseline rather than a race with it. await EventuallyAsync( () => server.PullCount > 0, "the pass on open should have run"); var before = server.PullCount; server.Notices.Push(Guid.CreateVersion7()); await EventuallyAsync( () => server.PullCount > before, "a notice should have woken the loop long before the one-minute timer"); } /// /// The half that is easy to get wrong. The loop selects between two waits, and both have to survive /// losing: PeriodicTimer throws if a second wait is started while one is outstanding, and an /// abandoned channel read stays registered and swallows the next notice written. Either defect /// leaves the first notice working and every one after it silently lost, which is why one notice is /// not enough to prove this. /// [Fact] public async Task NoticesKeepWakingTheLoop_NotJustTheFirst() { await UnlockedAsync(); await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run"); for (var round = 1; round <= 3; round++) { var before = server.PullCount; server.Notices.Push(Guid.CreateVersion7()); await EventuallyAsync( () => server.PullCount > before, $"notice {round} should have woken the loop as the first one did"); } } /// Waits for something a background loop is expected to do, or fails saying what. /// /// Polled rather than signalled because the thing under test is a loop nobody hands a completion /// source to. The bound is generous — this is not measuring latency, only proving that the timer /// cannot be what caused the result. /// private static async Task EventuallyAsync(Func condition, string because) { var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10); while (TimeProvider.System.GetUtcNow() < deadline) { if (condition()) { return; } await Task.Delay(TimeSpan.FromMilliseconds(20), Token); } throw new ShouldAssertException(because); } /// /// /// 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); } // ---- Which surface is showing ---- // // The tab strip is visible from every screen, so a terminal and a page are two things the window can be // showing rather than one screen among five. These fix that state machine. None of them can see the // WebView itself — headless Avalonia has no native window — but every transition below is decided here, // in ordinary objects, which is why they are worth having. /// /// The point of the whole rework, stated as one assertion: a terminal opened from somewhere other than /// the hosts screen shows, and the screen underneath it does not move. Moving it would make opening a /// terminal a way to lose your place in a transfer that is still running. /// [Fact] public async Task OpeningATerminalFromAnotherScreen_ShowsItAndLeavesTheScreenWhereItWas() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); shell.ShowScreenCommand.Execute(ShellScreen.Transfers); await vault.ConnectCommand.ExecuteAsync(null); shell.Surface.ShouldBe(ShellSurface.Terminal); shell.IsTerminalShowing.ShouldBeTrue(); shell.IsShowingPages.ShouldBeFalse(); shell.Screen.ShouldBe(ShellScreen.Transfers, "the page underneath is what closing the tab returns to"); } [Fact] public async Task ANavRailClick_HidesTheTerminalAndKeepsTheTab() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.IsTerminalShowing.ShouldBeFalse(); shell.IsShowingPages.ShouldBeTrue(); shell.IsKeychainShowing.ShouldBeTrue(); // The session is untouched. Navigating away from a terminal is not a way to end one; only closing // its tab is. var tab = shell.Tabs.ShouldHaveSingleItem(); tab.IsLive.ShouldBeTrue(); shell.SelectedTab.ShouldBe(tab); } [Fact] public async Task ClickingATab_BringsTheTerminalBackFromAPage() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.ShowScreenCommand.Execute(ShellScreen.Preferences); shell.IsTerminalShowing.ShouldBeFalse(); shell.SelectTabCommand.Execute(shell.Tabs[0]); shell.IsTerminalShowing.ShouldBeTrue(); shell.Screen.ShouldBe(ShellScreen.Preferences); } /// /// /// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its /// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab. /// /// /// Tested here rather than in the Android head because it is shared state-machine behaviour, and /// because nothing in this repository can run a test on a phone. /// /// [Fact] public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.IsTerminalShowing.ShouldBeFalse(); shell.ShowTerminalCommand.Execute(null); shell.IsTerminalShowing.ShouldBeTrue(); shell.IsShowingPages.ShouldBeFalse(); // The page underneath is remembered, not reset. Going to the terminal and back is navigation, and // navigation that forgets where you were is how a four-button bar becomes annoying. shell.Screen.ShouldBe(ShellScreen.Keychain); } /// /// The one connection this application makes to a machine that is not in the keychain. What is worth /// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and /// the port all come out of the one box. /// [Fact] public async Task AManualTarget_IsDialledExactlyAsItWasTyped() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ManualTarget = " deploy@build.internal:2222 "; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); var request = ssh.Requests.ShouldHaveSingleItem(); request.Host.ShouldBe("build.internal"); request.Port.ShouldBe(2222); request.Username.ShouldBe("deploy"); request.Credential.ShouldBeOfType().Password.ShouldBe("hunter2"); shell.IsTerminalShowing.ShouldBeTrue(); shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal"); } [Fact] public async Task AManualTargetWithNoPort_TakesTwentyTwo() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ManualTarget = "root@box"; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22); } /// /// ssh would fall back to this machine's own account name. A phone's is the Android user, which /// is never a login on anything, so the guess would fail at the remote as "authentication failed" /// rather than here as a sentence about the box that was typed into. /// [Theory] [InlineData("", "Type a machine")] [InlineData("build.internal", "Say who to log in as")] [InlineData("deploy@", "Say who to log in as")] [InlineData("@build.internal", "Say who to log in as")] [InlineData("deploy@build.internal:70000", "between 1 and 65535")] [InlineData("deploy@build.internal:ssh", "between 1 and 65535")] [InlineData("deploy@[fe80::1]", "bracketed IPv6")] public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled( string typed, string because) { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ManualTarget = typed; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); vault.ManualStatus.ShouldContain(because); ssh.Requests.ShouldBeEmpty(); shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it"); } /// /// A password is the only thing this path can authenticate with, so an empty one is refused here rather /// than sent. Offering the keychain's keys would be a second binding resolution beside the connect /// path's own, which is the thing TryBuildAuthentication exists to be the only copy of. /// [Fact] public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ManualTarget = "root@box"; await vault.ConnectManuallyCommand.ExecuteAsync(null); vault.ManualStatus.ShouldContain("password"); ssh.Requests.ShouldBeEmpty(); } /// /// The retry used to re-run whichever host was selected. That was right while a selected host was /// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a /// different machine — or by refusing with "choose a host first" over a key the user has just agreed to /// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and /// the assertion would still catch it. /// [Fact] public async Task TrustingAHostKey_RetriesTheAttemptThatRaisedItRatherThanTheSelectedHost() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.SelectedHost.ShouldNotBeNull(); ssh.Failure = new SshHostKeyUnknownException( new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown")); vault.ManualTarget = "deploy@build.internal:2222"; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeTrue(); ssh.Failure = null; await vault.TrustHostKeyCommand.ExecuteAsync(null); ssh.Requests.Count.ShouldBe(2); ssh.Requests[1].Host.ShouldBe("build.internal", "the retry is the attempt that asked the question"); ssh.Requests[1].Port.ShouldBe(2222); ssh.Requests[1].Username.ShouldBe("deploy"); } /// /// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The /// keychain half goes to the host's own connect bar rather than connecting from here, because that bar /// is where its key, its password box and its refusals already live. /// [Fact] public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen() { var vault = await ReadyToConnectAsync(); var host = vault.Hosts[0]; vault.SelectedHost = null; shell.ShowScreenCommand.Execute(ShellScreen.Preferences); shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId)); shell.IsHostsShowing.ShouldBeTrue(); vault.SelectedHost.ShouldBe(host); vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box"); } /// /// The other half. The log stored what was actually dialled, which is the grammar the manual box takes, /// so it goes straight back in — without the password, which was never stored and whose absence is the /// point of that path rather than a gap in it. /// [Fact] public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox() { var vault = await ReadyToConnectAsync(); // Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the // screen the application happens to open on. shell.ShowTerminalCommand.Execute(null); shell.ConnectToRecentCommand.Execute( Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null)); vault.ManualTarget.ShouldBe("deploy@build.internal:2222"); vault.ManualPassword.ShouldBeEmpty(); shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface"); } /// /// A host deleted since it was connected to. The machine is still there and the keychain no longer knows /// about it, so the address is the honest answer rather than a tap that does nothing. /// [Fact] public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress() { var vault = await ReadyToConnectAsync(); shell.ShowTerminalCommand.Execute(null); shell.ConnectToRecentCommand.Execute( Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7())); vault.ManualTarget.ShouldBe("root@prod-db:22"); shell.IsTerminalSurface.ShouldBeTrue(); } /// One row of the connection log, built by hand. /// /// Built rather than connected-and-closed, because what these three tests are about is which of the two /// branches a row takes — and driving that through a real connection, a real close and the recorder's own /// queue would test the recorder instead, which DodoSSH.Client.Session.Tests already does. /// private static ConnectionLogRowViewModel Recent(string label, string address, Guid? hostId) => new( new VaultItem( Guid.CreateVersion7(), new ConnectionLogSecret { HostLabel = label, Address = address, HostId = hostId, StartedAt = DateTimeOffset.UnixEpoch, DeviceName = "a phone", }, Version: 1, HasUnsyncedChanges: false, IsBlocked: false, IsReadOnly: false), isLive: false); // ---- Last connected ---- // // The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync // is the log read, and the two triggers below — the hosts screen coming back on screen and a session // ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md. /// /// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first /// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so /// none of this needs a fake clock — see the remark on VaultViewModel.DescribeElapsed itself. /// [Theory] [InlineData(0, "just now")] [InlineData(59, "just now")] [InlineData(60, "1 min ago")] [InlineData(150, "2 min ago")] [InlineData(3599, "59 min ago")] [InlineData(3600, "1 hr ago")] [InlineData(7200, "2 hr ago")] [InlineData(86399, "23 hr ago")] [InlineData(86400, "1 day ago")] [InlineData(172800, "2 days ago")] public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) => VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected); /// /// The hosts screen's own activation — one of the two moments VaultViewModel.RefreshLastConnectedAsync /// is read on. ReadyToConnectAsync already visited this screen once, before the log held anything /// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the /// transition MainWindowViewModel.UpdateLastConnectedVisibility actually keys off, rather than the /// level, which fired already. /// [Fact] public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog() { var vault = await ReadyToConnectAsync(); var host = vault.Hosts[0]; await vault.Session.ConnectionLog.CreateAsync( vault.Session.ActiveVaultId, new ConnectionLogSecret { HostLabel = host.Label, Address = host.Address, HostId = host.EntityId, StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3), DeviceName = "a workstation", }, Token); shell.ShowScreenCommand.Execute(ShellScreen.Preferences); shell.ShowScreenCommand.Execute(ShellScreen.Hosts); await EventuallyAsync( () => host.LastConnectedText.Length > 0, "activating the hosts screen should have read the log"); host.LastConnectedText.ShouldBe("3 days ago"); } /// /// A host the log has never named. Empty rather than a dash or the word "never" — see /// HostRowViewModel.LastConnectedText's own remarks: a host nobody has connected to and a host /// whose log simply has not been read yet look identical from this row, and neither is a claim it can /// make on its own. /// [Fact] public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText() { var vault = await ReadyToConnectAsync(); var host = vault.Hosts[0]; await vault.RefreshLastConnectedAsync(Token); host.LastConnectedText.ShouldBeEmpty(); } /// /// Decision 4's second half: a host with a session open right now shows the green dot instead of an /// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice, /// and the older answer is the one likeliest to be misread as current. /// [Fact] public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog() { var vault = await ReadyToConnectAsync(); var host = vault.Hosts[0]; await vault.Session.ConnectionLog.CreateAsync( vault.Session.ActiveVaultId, new ConnectionLogSecret { HostLabel = host.Label, Address = host.Address, HostId = host.EntityId, StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5), DeviceName = "a workstation", }, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); host.IsConnected.ShouldBeTrue(); await vault.RefreshLastConnectedAsync(Token); host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now"); } // The other trigger — a session ending on its own — has no test here. It runs inside // MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one // RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no // window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action // queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving // nothing about the one line this wave added, since the untestable half is wiring this wave did not // write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed // directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same // VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises. /// /// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette /// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu /// was raised from is part of it — see MainWindowViewModel.IsTerminalShowing. /// [Fact] public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.OpenConnectSheetCommand.Execute(null); shell.IsConnectSheetOpen.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle"); shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface"); shell.CloseConnectSheetCommand.Execute(null); shell.IsTerminalShowing.ShouldBeTrue(); } /// /// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can /// see keeping a terminal hidden that nothing would put back. /// [Fact] public async Task TheConnectSheet_RefusesToOpenOverAPage() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.OpenConnectSheetCommand.Execute(null); shell.IsConnectSheetOpen.ShouldBeFalse(); } /// /// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal /// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote /// the sheet for, like closing the last tab or locking, safe. /// [Fact] public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); // The menu's own second entry: one screen, over one view model, with the kind of remote chosen by // the thing that navigates. shell.OpenConnectSheetCommand.Execute(null); shell.ShowFilesCommand.Execute(RemoteKind.Bucket); shell.IsConnectSheetOpen.ShouldBeFalse(); shell.IsBucketsShowing.ShouldBeTrue(); // And a route the sheet was never wired to: back to the terminal, open it, then end the only shell // there is. shell.ShowTerminalCommand.Execute(null); shell.OpenConnectSheetCommand.Execute(null); shell.IsConnectSheetOpen.ShouldBeTrue(); await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]); shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page"); shell.IsShowingPages.ShouldBeTrue(); } /// /// A visible WebView with no pane in it reads as the application having broken, so this is the one /// transition that moves the surface back on its own. /// [Fact] public async Task ClosingTheLastTab_ReturnsToThePageThatWasShowing() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); shell.ShowScreenCommand.Execute(ShellScreen.Transfers); await vault.ConnectCommand.ExecuteAsync(null); await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]); shell.Tabs.ShouldBeEmpty(); shell.SelectedTab.ShouldBeNull(); shell.IsTerminalShowing.ShouldBeFalse(); shell.IsTransfersShowing.ShouldBeTrue("the page that was showing when the terminal opened"); } [Fact] public async Task ClosingOneOfTwoTabs_KeepsTheTerminalShowing() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null); shell.Tabs.Count.ShouldBe(2); // The selected one, which is the second. The neighbour takes its place and the terminal stays. await shell.CloseTabCommand.ExecuteAsync(shell.SelectedTab!); shell.SelectedTab.ShouldBe(shell.Tabs.ShouldHaveSingleItem()); shell.IsTerminalShowing.ShouldBeTrue(); } [Fact] public async Task ClosingATabThatIsNotSelected_ChangesNothingAboutTheSurface() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); var first = shell.Tabs[0]; await vault.ConnectCommand.ExecuteAsync(null); var second = shell.Tabs[1]; await shell.CloseTabCommand.ExecuteAsync(first); shell.SelectedTab.ShouldBe(second); shell.IsTerminalShowing.ShouldBeTrue(); } // ---- Connecting, while it is still happening ---- // // A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch // in which a tab exists and its session does not. Everything below is about that stretch: what the strip // shows, what the window draws in the terminal's rectangle, and what happens to the tab when the // connection answers — or does not. /// /// The point of the whole thing, stated as one assertion: the tab is in the strip before the connection /// has answered, and the vault is not busy while it waits. A user who asked for a machine that is asleep /// used to get a status line and a window that did nothing for as long as the timeout took. /// [Fact] public async Task Connecting_ShowsATabImmediatelyAndLeavesTheVaultUsable() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); tab.IsConnecting.ShouldBeTrue(); tab.HasSession.ShouldBeFalse(); tab.Label.ShouldBe("prod-db"); tab.Address.ShouldBe("deploy@db.internal:22", "named for what is being dialled, not for what answered"); // The card, not the renderer. They share one rectangle and there is no pane to put in it yet. shell.SelectedTab.ShouldBe(tab); shell.IsConnectingShowing.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse(); // The gate this command does not hold. Everything else on this screen still works, which is the // difference between waiting and being stuck. vault.IsBusy.ShouldBeFalse(); vault.Hosts.ShouldNotBeEmpty(); ssh.Gate.SetResult(); await connecting; tab.HasSession.ShouldBeTrue(); tab.IsLive.ShouldBeTrue(); tab.Status.ShouldBeEmpty("the pane speaks for itself from here on"); shell.IsTerminalShowing.ShouldBeTrue(); shell.IsConnectingShowing.ShouldBeFalse(); } /// /// /// What the card draws while the stretch above is going on. The tab used to carry one line of prose /// fixed at the moment it was created, which made a handshake stuck on a key exchange look exactly like /// one stuck on a dead socket — and made a connection that was progressing look exactly like one that /// was not. /// /// /// The gate is held open on the step the fake reports before it, so this asserts the state the card is /// actually drawn in rather than one it passes through: one step behind, one step lit, three not /// reached. Nothing here waits or polls, which is the other half of the claim — the report arrives on /// the thread that raised it and the tab is up to date in the same turn. /// /// [Fact] public async Task Connecting_LightsTheStepTheHandshakeHasActuallyReached() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); tab.Steps.Select(step => step.State).ShouldBe( [ ConnectionStepState.Done, ConnectionStepState.Running, ConnectionStepState.Pending, ConnectionStepState.Pending, ConnectionStepState.Pending, ], "the renderer attached, the host is being reached, and nothing after that has happened"); tab.StepsDone.ShouldBe(1, "the track fills to what finished, and the running step is not half a step"); tab.Status.ShouldBe("Reaching the host"); ssh.Gate.SetResult(); await connecting; tab.Steps.ShouldAllBe(step => step.IsDone, "a session that opened got through all of them"); tab.StepsDone.ShouldBe(tab.StepCount); } /// /// The half of the step list a progress bar could not do: where it stopped is kept, and the steps behind /// it stay done. That is the difference between "that host is not there" and "that host is there and /// would not have me", and it is the question the reason sentence alone often does not settle. /// [Fact] public async Task ARefusedConnection_KeepsTheStepItStoppedOn() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Failure = new InvalidOperationException("No route to host."); await vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); tab.Steps.Select(step => step.State).ShouldBe( [ ConnectionStepState.Done, ConnectionStepState.Stopped, ConnectionStepState.Pending, ConnectionStepState.Pending, ConnectionStepState.Pending, ], "it got as far as reaching the host and no further"); tab.Steps[1].Mark.ShouldBe("✕", "and says so without relying on the colour"); // The reason still goes where it always went. The list says how far, and this says what happened. tab.Status.ShouldBe("No route to host."); } /// /// A report that arrives for an attempt the shell has forgotten. Giving up on a connecting tab removes /// it while the handshake is still running — see CloseTabAsync — so every phase reported after /// that has no tab to land on. Dropped rather than resurrecting the tab, and above all not thrown: the /// handshake is still going, and its session is still adopted if it opens. /// [Fact] public async Task GivingUpOnATab_LeavesLaterPhasesWithNothingToDo() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); await shell.CloseTabCommand.ExecuteAsync(tab); // Everything after the gate — the host key, the credential, the shell — is reported to a shell that // no longer has a tab for this attempt. ssh.Gate.SetResult(); await connecting; // The session opened anyway and was adopted, which is the behaviour giving up already promised. var adopted = shell.Tabs.ShouldHaveSingleItem(); adopted.HasSession.ShouldBeTrue(); adopted.ShouldNotBe(tab); // And the forgotten tab was left where it was rather than being advanced from the sidelines. tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it"); } /// /// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite /// likely looking at another screen — which is exactly what not blocking bought. The tab is that place, /// and it stays until it is closed. /// [Fact] public async Task ARefusedConnection_LeavesATabCarryingTheReason() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Failure = new InvalidOperationException("No route to host."); await vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); tab.IsFailed.ShouldBeTrue(); tab.IsLive.ShouldBeFalse(); tab.Status.ShouldBe("No route to host."); shell.IsConnectingShowing.ShouldBeTrue("the card is where the reason is drawn"); shell.IsTerminalShowing.ShouldBeFalse(); // Closed like any other tab, and without asking the workspace to end a session that never existed. await shell.CloseTabCommand.ExecuteAsync(tab); shell.Tabs.ShouldBeEmpty(); shell.IsHostsShowing.ShouldBeTrue(); } /// /// The other kind of not-connecting. An unknown host key is a question rather than a failure, so the tab /// goes — a tab saying the connection failed would be competing with the decision that is about to resume /// it — and the window stays where it was, because the decision is drawn over whatever that is. /// [Fact] public async Task AnUnknownHostKey_TakesTheTabAwayAndShowsTheQuestion() { 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")); shell.ShowScreenCommand.Execute(ShellScreen.Transfers); await vault.ConnectCommand.ExecuteAsync(null); shell.Tabs.ShouldBeEmpty(); vault.HasPendingHostKey.ShouldBeTrue(); // It used to assert IsHostsShowing here, because the prompt was a banner on that screen and this // handler navigated to it. The screen is untouched now and the card is over it instead. shell.Screen.ShouldBe(ShellScreen.Transfers); shell.IsHostKeyDecisionShowing.ShouldBeTrue("and it has to be reachable from wherever the user is"); // And answering it connects, which is the whole reason the tab was not left saying it had failed. ssh.Failure = null; await vault.TrustHostKeyCommand.ExecuteAsync(null); shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue(); shell.IsHostKeyDecisionShowing.ShouldBeFalse("the decision goes with the answer"); } /// /// Refusing, and the state it leaves behind. Both halves have to clear: the changed-key refusal is drawn /// over the surface on both heads and the phone's is an opaque full-screen panel, so a dismissal that left /// the flag set would leave that panel up over every screen the user went to next — including the host /// editor it tells them to open. See VaultViewModel.RejectHostKey, which used to clear only the other one. /// [Theory] [InlineData(true)] [InlineData(false)] public async Task RefusingAHostKeyDecision_TakesItOffTheScreen(bool firstContact) { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Failure = firstContact ? new SshHostKeyUnknownException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown")) : new SshHostKeyMismatchException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"), "SHA256:the-pinned-key"); await vault.ConnectCommand.ExecuteAsync(null); shell.IsHostKeyDecisionShowing.ShouldBeTrue(shell.StatusMessage); vault.RejectHostKeyCommand.Execute(null); vault.HasPendingHostKey.ShouldBeFalse(); vault.HasHostKeyMismatch.ShouldBeFalse(); shell.IsHostKeyDecisionShowing.ShouldBeFalse(); // Nothing was pinned either way, so the machine is still a first contact next time. (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } /// /// /// The one thing about the card that nothing else here can see, and the whole arrangement rests on it: the /// question belongs to the vault and the flag that draws the card — and collapses the terminal under it — /// belongs to the shell, so the shell has to re-raise it. Without that the card would never appear and, /// worse, never go away. /// /// /// Nothing else in this suite watches PropertyChanged, and this is why it is worth being the first: /// every other assertion about the flag reads it directly, and a direct read passes with the subscription /// deleted. The departure is the half that matters — OnVaultConnectionFailed raises the state /// itself as the tab goes, so an arrival is announced twice over, and answering the question is announced /// only from the vault's own notification. /// /// [Fact] public async Task TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); var announcements = 0; shell.PropertyChanged += OnShellPropertyChanged; try { ssh.Failure = new SshHostKeyUnknownException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown")); await vault.ConnectCommand.ExecuteAsync(null); announcements.ShouldBeGreaterThan(0, "the card has to be told to appear"); announcements = 0; vault.RejectHostKeyCommand.Execute(null); announcements.ShouldBeGreaterThan(0, "and to go, which is the half a stale flag would trap"); } finally { shell.PropertyChanged -= OnShellPropertyChanged; } void OnShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (string.Equals( e.PropertyName, nameof(MainWindowViewModel.IsHostKeyDecisionShowing), StringComparison.Ordinal)) { announcements++; } } } /// /// Giving up on a connection that is still in flight. The tab goes at once — that is what the button /// promises — and the handshake that finishes afterwards is adopted rather than dropped, because a shell /// running with nothing in the window naming it is worse than a tab that comes back. /// [Fact] public async Task ClosingATabThatIsStillConnecting_TakesItAwayAndKeepsWhateverArrives() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]); shell.Tabs.ShouldBeEmpty(); shell.IsHostsShowing.ShouldBeTrue(); ssh.Gate.SetResult(); await connecting; shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue("the session is real, so it gets a tab"); } /// /// Two at once, which is the other thing not holding the vault made possible — and the reason an attempt /// carries an id rather than being found by the host's name. /// [Fact] public async Task TwoConnectionsCanBeInFlightAtOnce() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await AddHostAsync(vault, "stage-web"); ssh.Gate = new TaskCompletionSource(); vault.SelectedHost = Host(vault, "prod-db"); var first = vault.ConnectCommand.ExecuteAsync(null); vault.SelectedHost = Host(vault, "stage-web"); var second = vault.ConnectCommand.ExecuteAsync(null); shell.Tabs.Select(tab => tab.Label).ShouldBe(["prod-db", "stage-web"]); shell.Tabs.ShouldAllBe(tab => tab.IsConnecting); ssh.Gate.SetResult(); await first; await second; shell.Tabs.ShouldAllBe(tab => tab.HasSession); } /// /// Locking does not end a handshake any more than it ends a shell, and the tab standing in for one is /// shell state that survives a lock. So the answer still has to arrive somewhere: without it the tab /// would say "connecting…" for ever and the session it opened would have nothing naming it, and so no /// way to be closed. /// [Fact] public async Task AConnectionInFlightWhenTheVaultLocks_StillLandsInItsTab() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); await shell.LockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Locked); shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "tabs outlive the vault that opened them"); ssh.Gate.SetResult(); await connecting; tab.HasSession.ShouldBeTrue(); tab.IsLive.ShouldBeTrue(); } /// /// A tab is marked by whether its terminal is the thing on screen, not by whether it is the selected /// one — the selection survives navigating away, which is what makes the strip a way back rather than a /// way to lose a shell. Two "you are here" marks at once is one too many, and the rail's own entries /// already make the same distinction. /// [Fact] public async Task ATabIsMarkedOnlyWhileItsTerminalIsShowing() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); ssh.Gate = new TaskCompletionSource(); var connecting = vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); tab.IsShowing.ShouldBeTrue("the connecting card is what the window is showing"); // Navigating away during the connection, which is the case this most exists for: the connection goes // on, the tab stays selected, and nothing in the strip claims to be on screen. shell.ShowScreenCommand.Execute(ShellScreen.Keychain); tab.IsShowing.ShouldBeFalse(); tab.IsSelected.ShouldBeTrue("navigating away is not deselecting"); ssh.Gate.SetResult(); await connecting; tab.IsShowing.ShouldBeFalse("a connection that finishes while you are elsewhere does not grab the window"); shell.SelectTabCommand.Execute(tab); tab.IsShowing.ShouldBeTrue(); // And the palette, which draws over the same rectangle. shell.ToggleSearchCommand.Execute(null); tab.IsShowing.ShouldBeFalse(); } /// /// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the /// terminal share a rectangle, and the card is the one that has to win. /// [Fact] public async Task Locking_HidesTheTerminalWhateverTheSurfaceWas() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.IsTerminalShowing.ShouldBeTrue(); await shell.LockCommand.ExecuteAsync(null); shell.IsTerminalShowing.ShouldBeFalse(); shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue("locking does not end a session"); } [Fact] public async Task AnUnlock_LandsOnAPageEvenWithATabStillOpen() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); await shell.LockCommand.ExecuteAsync(null); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); shell.IsHostsShowing.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse(); } [Fact] public async Task ThePalette_HidesTheTerminalAndClosingItBringsItBack() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.ToggleSearchCommand.Execute(null); shell.IsSearching.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse("the palette draws over the terminal's rectangle"); shell.CloseSearchCommand.Execute(null); shell.IsTerminalShowing.ShouldBeTrue(); } /// /// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a /// terminal fills the window would point at a screen that is not showing — and the selected tab already /// carries that mark, in the strip. /// [Fact] public async Task TheNavRailLightsExactlyOneEntryOnAPage_AndNoneOnATerminal() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); LitEntries().ShouldBe(1); await vault.ConnectCommand.ExecuteAsync(null); LitEntries().ShouldBe(0); shell.ShowScreenCommand.Execute(ShellScreen.Keychain); LitEntries().ShouldBe(1); shell.IsKeychainShowing.ShouldBeTrue(); int LitEntries() => new[] { shell.IsHostsShowing, shell.IsTransfersShowing, shell.IsKeychainShowing, shell.IsVaultsShowing, shell.IsPreferencesShowing, }.Count(lit => lit); } /// /// /// The palette can be opened from any screen, and this used to be why an unknown host key moved the /// window: the prompt was a banner on the hosts screen, so the shell navigated there before letting the /// vault raise it, or the connection would have blocked on a question behind whatever the user was /// looking at. /// /// /// The decision is drawn over the surface now, on both heads, so nothing moves — and this is the same /// test inverted, kept rather than deleted because the requirement it was written for still holds. What /// changed is how it is met: the question has to be answerable from where the user is, not the user /// taken to where the question is. /// /// [Fact] public async Task ConnectingFromThePalette_AsksAboutTheHostKeyWithoutMovingTheWindow() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); shell.ShowScreenCommand.Execute(ShellScreen.Transfers); ssh.Failure = new SshHostKeyUnknownException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown")); shell.ToggleSearchCommand.Execute(null); shell.SelectedSearchResult = shell.SearchResults[0]; await shell.ConnectToSearchResultCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeTrue(); shell.Screen.ShouldBe( ShellScreen.Transfers, "the transfer the user was looking at is still what is underneath"); shell.IsHostKeyDecisionShowing.ShouldBeTrue("and the card is over it"); // No assertion about the renderer here, deliberately: this attempt's tab was the only one, so it is // collapsed for want of a session whatever the occlusion rule says, and a test that cannot fail is // worse than no test. That claim belongs where a live terminal is behind the card — see // AChangedHostKey_CollapsesTheTerminalItIsRefusedOver. } /// /// The connection with no host, which is the case the old navigation was worst for: it took the box that /// was typed into away and asked about the machine on a list the machine is deliberately not on. The /// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet — /// and what the user comes back to whichever way they answer. /// [Fact] public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); shell.ShowTerminalCommand.Execute(null); ssh.Failure = new SshHostKeyUnknownException( new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown")); vault.ManualTarget = "deploy@build.internal:2222"; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeTrue(); shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away"); shell.IsHostKeyDecisionShowing.ShouldBeTrue(); } /// /// A changed key is the other half of the same control and the reason it collapses the renderer: a second /// connection can be refused while a first one is open, and the refusal has to be readable over it. /// [Fact] public async Task AChangedHostKey_CollapsesTheTerminalItIsRefusedOver() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); // One live session first, so there is something in the rectangle for the refusal to be drawn over. await vault.ConnectCommand.ExecuteAsync(null); shell.IsTerminalShowing.ShouldBeTrue(shell.StatusMessage); ssh.Failure = new SshHostKeyMismatchException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"), "SHA256:the-pinned-key"); await vault.ConnectCommand.ExecuteAsync(null); vault.HasHostKeyMismatch.ShouldBeTrue(); shell.IsHostKeyDecisionShowing.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse("the card would otherwise be sliced at the WebView's edge"); // And it comes back when the refusal is put away, rather than needing a tab click to restore it. vault.RejectHostKeyCommand.Execute(null); shell.IsHostKeyDecisionShowing.ShouldBeFalse(); shell.IsTerminalShowing.ShouldBeTrue(); } [Fact] public async Task TrustingAHostKey_PinsItInTheVaultAndConnects() { 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:first-contact")); await vault.ConnectCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeTrue(); // The second connection is the one that succeeds, which is what a trust-and-retry actually is: the // handshake is refused, the user decides, and a fresh connection is made with the pin in place. ssh.Failure = null; await vault.TrustHostKeyCommand.ExecuteAsync(null); vault.HasPendingHostKey.ShouldBeFalse(); // Two connection attempts: the one that was refused and the one the pin allowed. Asserted on the // factory rather than on the status line, which the push that follows a trust legitimately repaints. ssh.Requests.Count.ShouldBe(2); (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)) .ShouldBe("SHA256:first-contact"); // Pushed as part of trusting, so the next machine to sync is not asked the same question. vault.PendingChanges.ShouldBe(0); } [Fact] public async Task APinnedHostKey_SurvivesLockingAndUnlocking() { // The gap this whole item closes, from the shell's point of view: the store is opened on unlock and // its contents come out of the vault, so approving a fingerprint is a decision that lasts. 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:approved")); await vault.ConnectCommand.ExecuteAsync(null); ssh.Failure = null; await vault.TrustHostKeyCommand.ExecuteAsync(null); await shell.LockCommand.ExecuteAsync(null); // Locked means locked: the pins go with the vault keys, so nothing can answer a host key question // while the window is showing an unlock screen. (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved"); } [Fact] public async Task ForgettingAHostKey_ClearsThePinAndTheRefusal() { // The way back from a rebuilt server, and the reason a mismatch can stay a hard refusal: the user // withdraws trust deliberately, from the host's own editor, rather than clicking past a warning. 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:the-old-key")); await vault.ConnectCommand.ExecuteAsync(null); ssh.Failure = null; await vault.TrustHostKeyCommand.ExecuteAsync(null); // The server is rebuilt and offers something else. ssh.Failure = new SshHostKeyMismatchException( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"), "SHA256:the-old-key"); await vault.ConnectCommand.ExecuteAsync(null); vault.HasHostKeyMismatch.ShouldBeTrue(); vault.EditSelectedHostCommand.Execute(null); vault.CanForgetHostKey.ShouldBeTrue(); await vault.ForgetHostKeyCommand.ExecuteAsync(null); // The refusal that sent the user here is about a pin that no longer exists, so it goes too. vault.HasHostKeyMismatch.ShouldBeFalse(); (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); // And the withdrawal was pushed rather than left for the timer: the other machines are the ones // still refusing to connect to a server that has been rebuilt. The wording of the message is // asserted in ForgettingAHostKeyThatWasNeverPinned_SaysSo, where no pass overwrites the status. vault.PendingChanges.ShouldBe(0); } [Fact] public async Task ForgettingAHostKeyThatWasNeverPinned_SaysSo() { var vault = await ReadyToConnectAsync(); vault.EditSelectedHostCommand.Execute(null); await vault.ForgetHostKeyCommand.ExecuteAsync(null); vault.Status.ShouldContain("Nothing was pinned"); } [Fact] public async Task ThereIsNothingToForgetOnAHostThatDoesNotExistYet() { // The button is hidden while a host is being created, because the pin belongs to an address that has // not been saved anywhere yet. var vault = await ReadyToConnectAsync(); vault.NewHostCommand.Execute(null); vault.CanForgetHostKey.ShouldBeFalse(); vault.CancelEditCommand.Execute(null); vault.CanForgetHostKey.ShouldBeFalse(); vault.EditSelectedHostCommand.Execute(null); vault.CanForgetHostKey.ShouldBeTrue(); } /// /// 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 DeleteSelectedHostAsync(vault); 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); } // ---- The question in front of a deletion ---- /// /// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A /// card that appeared after the item had already gone would be a receipt, not a question. /// [Fact] public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; vault.DeleteHostCommand.Execute(null); var question = vault.PendingDeletion.ShouldNotBeNull(); question.Question.ShouldContain("prod-db", Case.Insensitive); vault.IsConfirmingDeletion.ShouldBeTrue(); vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces"); vault.Hosts.ShouldHaveSingleItem(); server.LiveRowCount.ShouldBe(1); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Hosts.ShouldBeEmpty(); vault.PendingDeletion.ShouldBeNull("the question goes when it is answered"); vault.ShowsHostActions.ShouldBeTrue(); } [Fact] public async Task CancellingADeletion_LeavesTheItemWhereItWas() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy"); vault.SelectedCredential = vault.Credentials[0]; vault.DeleteCredentialCommand.Execute(null); vault.CancelDeleteCommand.Execute(null); vault.PendingDeletion.ShouldBeNull(); // And the answer that would have deleted it has nothing left to act on. await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Credentials.ShouldHaveSingleItem(); server.LiveRowCount.ShouldBe(1); } /// /// What the question is for. A key that two hosts authenticate with is not the same deletion as one /// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is /// asserted from the connect path's side in /// . /// [Fact] public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); var keyId = vault.Keys[0].EntityId; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await BindKeyAsync(vault, Host(vault, "prod-db"), keyId); await BindKeyAsync(vault, Host(vault, "prod-web"), keyId); vault.SelectedKey = vault.Keys[0]; vault.DeleteKeyCommand.Execute(null); var question = vault.PendingDeletion.ShouldNotBeNull(); question.HasUsage.ShouldBeTrue(); question.Usage.ShouldContain("2 hosts"); question.Usage.ShouldContain("prod-db"); question.Usage.ShouldContain("prod-web"); // And the sentence above it says how far the deletion travels, which needs no host at all. question.Consequence.ShouldContain("no undo", Case.Insensitive); } /// /// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared /// every time would say nothing the second time. /// [Fact] public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "spare"); await AddHostAsync(vault, "prod-db"); vault.SelectedKey = vault.Keys[0]; vault.DeleteKeyCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse(); } /// /// The failure this guards against is a question answered about something else: arm the deletion, click /// another row, press the button that is still on screen. The armed item is what the answer acts on, and /// choosing a different one takes the question away rather than re-aiming it. /// [Fact] public async Task ChoosingSomethingElse_TakesTheQuestionAway() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy"); await AddCredentialAsync(vault, "staging deploy"); await vault.LoadAsync(Token); vault.Section = VaultSection.Credentials; vault.SelectedVaultItem = vault.VaultItems[0]; vault.DeleteCredentialCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull(); vault.SelectedVaultItem = vault.VaultItems[1]; vault.PendingDeletion.ShouldBeNull(); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Credentials.Count.ShouldBe(2, "nothing was agreed to"); } /// /// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on /// any change of the selected row would let the pass that runs every minute take the card away /// from somebody halfway through reading it. The entity id is what the rule compares. /// [Fact] public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; vault.DeleteHostCommand.Execute(null); var armed = vault.PendingDeletion.ShouldNotBeNull(); await vault.SyncCommand.ExecuteAsync(null); await vault.LoadAsync(Token); vault.PendingDeletion.ShouldBe(armed); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Hosts.ShouldBeEmpty(); } /// /// Opening an editor is the other way the pane the question is in stops being about the question: the /// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over /// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column. /// [Fact] public async Task OpeningAnEditor_TakesTheQuestionAway() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy"); vault.SelectedCredential = vault.Credentials[0]; vault.DeleteCredentialCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull(); vault.NewCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeTrue(); vault.PendingDeletion.ShouldBeNull(); } /// /// An answer to a question about something that has since gone — the realistic way being a pass that /// pulled somebody else's deletion. The reload that brings that news normally moves the selection and /// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement /// from being a silent no-op under a card that has just been pressed. /// [Fact] public async Task AnsweringAboutSomethingAlreadyGone_SaysSo() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); vault.SelectedKey = vault.Keys[0]; vault.DeleteKeyCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull(); // Underneath the question, as another machine's deletion would arrive. await vault.Session.SshKeys.DeleteAsync( vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token); await vault.LoadAsync(Token); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Status.ShouldContain("no longer here"); } [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 VaultKnownHostStore(), new UnavailableDeviceKeyStore(), (_, _) => throw new InvalidOperationException("unreachable"), TimeProvider.System, ssh, 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, progress: null, 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 DeleteSelectedKeyAsync(vault); vault.Keys.ShouldBeEmpty(); server.LiveRowCount.ShouldBe(0); vault.PendingChanges.ShouldBe(0); } // ---- One kind of item at a time ---- /// /// Hosts are not one of these any more. They have their own screen beside the terminal, which is what the /// design asks for and is the better split anyway: the host list is what you look at while you work, and /// the keys and passwords behind it are what you go and manage. What is left here is the vault screen's /// own rail, and it opens on everything at once because the categories are a filter over one table /// rather than four separate lists. /// [Fact] public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories() { await UnlockedAsync(); var vault = shell.Vault!; vault.Section.ShouldBe(VaultSection.All); vault.ShowsAll.ShouldBeTrue(); vault.ShowsKeys.ShouldBeFalse(); vault.ShowSectionCommand.Execute(VaultSection.Keys); vault.ShowsKeys.ShouldBeTrue(); vault.ShowsAll.ShouldBeFalse("both flags are one fact read two ways and cannot both be true"); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsAll.ShouldBeTrue(); } /// /// The merged category is what the design's one credential table exists for, and the thing worth pinning /// about it is that it is a projection rather than a fifth list: every row maps back to the typed row the /// editors and the delete commands already act on. /// [Fact] public async Task TheMergedTableCarriesEveryKindAndSelectingARowSelectsTheTypedOne() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "pg-primary", "s3cret"); // Adding leaves the rail on whatever was added last, because opening an editor brings its own // category into view. Back to the merged one, which is where the screen opens. vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsAll.ShouldBeTrue(); vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy", "pg-primary"]); vault.VaultItems.Select(row => row.Type).ShouldBe(["SSH KEY", "PASSWORD"]); vault.SelectedVaultItem = vault.VaultItems.First(row => row.Kind is VaultItemKind.Credential); vault.SelectedCredential.ShouldNotBeNull(); vault.SelectedCredential.Label.ShouldBe("pg-primary"); vault.SelectedItemIsEditable.ShouldBeTrue(); // And narrowing to one kind does not disturb what is selected underneath. vault.ShowSectionCommand.Execute(VaultSection.Keys); vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy"]); vault.SelectedCredential.Label.ShouldBe("pg-primary", "narrowing the view is not a deselection"); } /// /// The invariant that makes an unreachable editor impossible: an editor is only ever open in the section /// that is showing. Without it, adding a key from a keyboard shortcut or a future menu would open an /// editor nobody can see, holding a private key nobody can cancel. /// [Fact] public async Task OpeningAnEditorBringsItsOwnSectionIntoView() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddKeyAsync(vault, "deploy"); vault.Section = VaultSection.All; vault.NewKeyCommand.Execute(null); vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open with the rail pointing elsewhere"); vault.CancelKeyEditCommand.Execute(null); // And through the other door into the editor. vault.Section = VaultSection.All; vault.SelectedKey = vault.Keys[0]; vault.EditSelectedKeyCommand.Execute(null); vault.ShowsKeys.ShouldBeTrue(); vault.CancelKeyEditCommand.Execute(null); // The host editor is the exemption, and it is the point of the split: hosts are a screen of their // own, so opening their editor has no category to bring into view and must not move the rail. vault.Section = VaultSection.Keys; vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue(); vault.ShowsKeys.ShouldBeTrue("editing a host is not a reason to move the vault screen's rail"); } /// /// The refusal that keeps the rule above true. Leaving the section while a vault-screen editor is open /// would hide it, and in the key editor's case that means a pasted private key sitting in a form with /// nothing on screen to say it is there. /// [Fact] public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE"); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor"); vault.Status.ShouldContain("SSH key"); vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE")); // A refusal, not a lockout: dealing with the editor releases the selector. vault.CancelKeyEditCommand.Execute(null); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsAll.ShouldBeTrue(); } /// /// The host editor lives on a different screen from the rail, so it does not guard the rail — nothing /// about a half-typed host is visible or at risk from switching the vault screen's own category, and /// blocking it here used to leave three quarters of that screen inert with a message pointing at an /// editor the user could not see. /// [Fact] public async Task SwitchingSectionIsNotBlockedByAnOpenHostEditor() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.EditorLabel = "half-typed"; vault.ShowSectionCommand.Execute(VaultSection.Keys); vault.ShowsKeys.ShouldBeTrue("a host editor on another screen has nothing to say about this rail"); vault.IsEditing.ShouldBeTrue("switching category must not close the host editor either"); vault.EditorLabel.ShouldBe("half-typed"); } /// /// Asking for the section that is already showing is not a refusal, so a second click on the selected /// button while an editor is open says nothing. Worth pinning because the obvious implementation — check /// the editor, then compare — would scold somebody for clicking where they already are. /// [Fact] public async Task ReselectingTheSectionAlreadyShowingSaysNothing() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.Status = string.Empty; vault.ShowSectionCommand.Execute(VaultSection.All); vault.Status.ShouldBeEmpty(); vault.IsEditing.ShouldBeTrue(); } /// /// One editor open at a time within the vault screen, still — but no longer across it and the /// Hosts screen. Both editors used to be Auto rows in one 340-pixel column whose combined height /// exceeded it; sections ended that, and the design import gave hosts their own screen, so the host /// editor no longer shares any column, any visibility or any risk with the key and credential editors. /// What the rule still buys, inside the vault screen, is that an open key editor is always one somebody /// can see, because it is holding their private key. /// [Fact] public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE"); vault.NewHostCommand.Execute(null); // Both open at once: the host editor is on a screen of its own, and there is nothing for it to // clip or hide on the vault screen the key editor is on. vault.IsEditing.ShouldBeTrue("the host editor is a different screen's business now"); vault.IsEditingKey.ShouldBeTrue("and does not close the vault screen's own editor"); vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE")); } /// /// One vault-screen editor at a time, still. The key and credential editors share the same screen and /// the same detail pane, so opening one over the other is exactly the case the rule exists for — unlike /// the host editor, which does not. /// [Fact] public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewKeyCommand.Execute(null); vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE"); vault.NewCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeFalse("the credential 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.NewCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeTrue(); // Symmetrically, with the credential editor holding the screen. vault.CredentialEditorPassword = "half-typed"; vault.NewKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeFalse(); vault.CredentialEditorPassword.ShouldBe("half-typed"); vault.Status.ShouldContain("credential"); } [Fact] public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherKeychainScreenEditorToo() { // The Edit commands are a second door into the same screen, and guarding only the Add ones would // leave it wide open. await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "pg-primary", "s3cret"); vault.SelectedKey = vault.Keys[0]; vault.SelectedCredential = vault.Credentials[0]; vault.NewKeyCommand.Execute(null); vault.EditSelectedCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeFalse(); vault.CancelKeyEditCommand.Execute(null); vault.NewCredentialCommand.Execute(null); vault.EditSelectedKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeFalse(); } /// /// The host editor's own version of the same rule: opening a second host editor over the first, or /// editing an existing host while adding one, is refused — this is the one case the split guard still /// has to cover, because both doors lead to the same single editor on the Hosts screen. /// [Fact] public async Task TheHostEditorRefusesToOpenOverItself() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; vault.NewHostCommand.Execute(null); vault.EditorLabel = "half-typed"; vault.EditSelectedHostCommand.Execute(null); vault.EditorLabel.ShouldBe("half-typed", "the second door must not discard the first editor's draft"); vault.Status.ShouldContain("host"); } /// /// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono /// count beside every category row, and this pins that each one is the same number the category's own /// list already carries rather than a second, hand-kept tally that could drift from it. /// [Fact] public async Task TheCategoryRailCountsMatchTheUnderlyingLists() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddKeyAsync(vault, "backup"); await AddCredentialAsync(vault, "pg-primary", "s3cret"); await AddTagAsync(vault, "production"); vault.Keys.Count.ShouldBe(2); vault.Credentials.Count.ShouldBe(1); vault.Tags.Count.ShouldBe(1); vault.ObjectStores.Count.ShouldBe(0); vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included"); } /// /// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a /// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance — /// which is the whole reason the column exists. /// [Fact] public async Task EachVaultItemKindCarriesItsOwnGlyph() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "pg-primary", "s3cret"); await AddTagAsync(vault, "production"); vault.ShowSectionCommand.Execute(VaultSection.All); var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph); byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty(); byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty(); byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty(); new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] } .Distinct(StringComparer.Ordinal).Count() .ShouldBe(3, "three kinds on the same table read as three different glyphs"); } /// /// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had /// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and /// clearing it brings every row straight back. /// [Fact] public async Task TheItemFilterNarrowsTheMergedTableByNameOrType() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy-key"); await AddCredentialAsync(vault, "pg-primary", "s3cret"); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ItemFilter = "deploy"; vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]); vault.ItemFilter = "PASSWORD"; vault.VaultItems.Select(row => row.Name).ShouldBe( ["pg-primary"], "the type word matches too, case-insensitively"); vault.ItemFilter = string.Empty; vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion"); } /// /// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all /// three read the same resolved-binding scan HostsBoundTo already used for the deletion warning, /// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of /// them going stale relative to the others. /// [Fact] public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-api-01"); await AddKeyAsync(vault, "deploy"); var key = vault.Keys[0]; await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId); vault.ShowSectionCommand.Execute(VaultSection.All); var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key); row.UsedBySummary.ShouldBe("prod-api-01"); row.HasUsedBySummary.ShouldBeTrue(); vault.SelectedVaultItem = row; vault.HasSelectedItemUsedByHosts.ShouldBeTrue(); vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01"); vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host"); // And a key nothing authenticates with says none of this — never a zero-count claim. await AddKeyAsync(vault, "unused"); vault.SelectedVaultItem = vault.VaultItems.Single( item => string.Equals(item.Name, "unused", StringComparison.Ordinal)); vault.HasSelectedItemUsedByHosts.ShouldBeFalse(); vault.SelectedItemInUseSummary.ShouldBe(string.Empty); } // ---- Keeping an open editor's pickers in step with the vault ---- /// /// The other side of the split guard, and the bug it left behind. The host editor and the keychain's /// editors are on different screens and refuse each other no longer, so a key is very often added /// because the host in front of the user needs one — with that host's editor still standing on the /// Hosts screen. The picker was a snapshot taken when the editor opened, so the key never appeared in it /// and the only way to reach it was to abandon the edit and start again. /// [Fact] public async Task AKeyAddedWithTheHostEditorOpen_AppearsInItsAuthenticationPicker() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorPort = 2244; await AddKeyAsync(vault, "deploy"); vault.IsEditing.ShouldBeTrue("adding a key must not close the host editor"); vault.EditorPort.ShouldBe(2244, "nor discard what has been typed into it"); var offered = vault.EditorAuthenticationChoices.Single( choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal)); offered.Kind.ShouldBe(AuthenticationKind.SshKey); offered.EntityId.ShouldBe(vault.Keys[0].EntityId); // A real entry rather than a label: choosing it and saving is what the user came here to do. vault.EditorSelectedAuthentication = offered; await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(vault.Keys[0].EntityId); vault.Hosts[0].Host.Port.ShouldBe(2244); } /// /// The counterweight, and the reason the refill restores each picker by id rather than reloading the /// stored host: a list that grows under somebody halfway through a form must not move what they had /// already chosen in it, and the save that follows must not rebind the host to something nobody picked. /// [Fact] public async Task AnItemArrivingWhileTheHostEditorIsOpen_LeavesItsSelectionWhereItWas() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); var keyId = vault.Keys.ShouldHaveSingleItem().EntityId; await BindKeyAsync(vault, vault.Hosts[0], keyId); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); await AddCredentialAsync(vault, "pg-primary"); vault.EditorAuthenticationChoices.ShouldContain( choice => choice.Kind == AuthenticationKind.Credential); vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId .ShouldBe(keyId, "a credential appearing must not unbind the host from its key"); await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId); vault.Hosts[0].Host.CredentialId.ShouldBeNull(); } /// /// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and /// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the /// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from /// the vault and restores it from the editor's own selection, so the binding is written before the /// reload rather than after it. /// [Fact] public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.BeginEditorCredentialCommand.Execute(null); vault.IsAddingEditorCredential.ShouldBeTrue(); vault.EditorNewCredentialLabel = "pg-primary"; vault.EditorNewCredentialUsername = "postgres"; vault.EditorNewCredentialPassword = "s3cret"; vault.EditorNewCredentialNotes = "rotated quarterly"; await vault.AddEditorCredentialCommand.ExecuteAsync(null); var credential = vault.Credentials.ShouldHaveSingleItem(); credential.Credential.Username.ShouldBe("postgres"); credential.Credential.Notes.ShouldBe("rotated quarterly"); vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain"); vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password"); vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe( credential.EntityId, "the picker has to land on the credential that was just made, through the reload"); await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId); vault.Hosts[0].Host.SshKeyId.ShouldBeNull(); } /// /// The honest consequence of writing immediately, and the same one the new-tag box already carries: a /// credential is a shared item with an id, the host can only name an id that exists, so the credential /// was never part of the host to begin with. What was still being typed is a different matter — that /// includes a password, and it goes with the editor it was typed into. /// [Fact] public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.BeginEditorCredentialCommand.Execute(null); vault.EditorNewCredentialLabel = "pg-primary"; vault.EditorNewCredentialPassword = "s3cret"; await vault.AddEditorCredentialCommand.ExecuteAsync(null); // A second one, opened and left half-typed. vault.BeginEditorCredentialCommand.Execute(null); vault.EditorNewCredentialLabel = "half"; vault.EditorNewCredentialPassword = "typed-but-never-added"; vault.EditorNewCredentialNotes = "half a thought"; vault.CancelEditCommand.Execute(null); vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary"); vault.IsAddingEditorCredential.ShouldBeFalse(); vault.EditorNewCredentialLabel.ShouldBeEmpty(); vault.EditorNewCredentialNotes.ShouldBeEmpty(); vault.EditorNewCredentialPassword.ShouldBeEmpty( "a password typed into an abandoned form must not survive behind the next host"); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull( "the binding itself was never saved"); } /// /// Why this form has fields of its own rather than reusing the keychain screen's four. /// IsEditingCredential is what AVaultEditorIsInTheWay asks about, so sharing it would make /// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see /// on a screen they are not looking at. That is the exact failure the guard was split in two to end. /// [Fact] public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.BeginEditorCredentialCommand.Execute(null); vault.NewCredentialCommand.Execute(null); vault.IsEditingCredential.ShouldBeTrue( "the keychain's editor lives on another screen and opens regardless"); } /// /// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather /// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called /// "root" are two different passwords, and quietly binding the host to whichever was there already /// would authenticate it as an account nobody chose. /// [Fact] public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential() { var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "root", password: "first"); var first = vault.Credentials.ShouldHaveSingleItem().EntityId; vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.BeginEditorCredentialCommand.Execute(null); vault.EditorNewCredentialLabel = "root"; vault.EditorNewCredentialPassword = "second"; await vault.AddEditorCredentialCommand.ExecuteAsync(null); vault.Credentials.Count.ShouldBe(2); vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe( first, "binding to the credential that happened to share the name would be the wrong password"); } /// /// The same refusal CredentialSecret.TryValidate gives the keychain's editor, reaching the user /// here rather than producing an item that looks usable and fails at the handshake. /// [Fact] public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.BeginEditorCredentialCommand.Execute(null); vault.EditorNewCredentialLabel = "pg-primary"; await vault.AddEditorCredentialCommand.ExecuteAsync(null); vault.Credentials.ShouldBeEmpty(); vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused"); vault.Status.ShouldContain("password"); } /// /// Tags reach the same editor by a different route — the keychain screen rather than the box under the /// chips — and a chip that only appeared on the next open would send the user round the same detour. /// [Fact] public async Task ATagAddedWithTheHostEditorOpen_AppearsAmongItsChips() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); await AddTagAsync(vault, "production"); vault.HasTagChoices.ShouldBeTrue(); var chip = vault.EditorTagChoices.ShouldHaveSingleItem(); chip.Label.ShouldBe("production"); chip.IsWorn.ShouldBeFalse("appearing is not the same as being put on"); } /// /// The group editor shares the drawer with the host editor and its own picker was the same snapshot, so /// the same detour applied to the default binding a whole group of hosts inherits. /// [Fact] public async Task AKeyAddedWithTheGroupEditorOpen_AppearsInItsDefaultBindingPicker() { await UnlockedAsync(); var vault = shell.Vault!; await AddGroupAsync(vault, "platform"); vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem()); vault.GroupEditorDefaultPort = 2222; await AddKeyAsync(vault, "deploy"); vault.IsEditingGroup.ShouldBeTrue("adding a key must not close the group editor"); vault.GroupEditorDefaultPort.ShouldBe(2222, "nor discard what has been typed into it"); vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single( choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal)); await vault.SaveGroupCommand.ExecuteAsync(null); vault.Groups.ShouldHaveSingleItem().Group.DefaultSshKeyId.ShouldBe(vault.Keys[0].EntityId); vault.Groups[0].Group.DefaultPort.ShouldBe(2222); } // ---- 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.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(keyId); vault.EditorAuthenticationChoices[0].Kind .ShouldBe(AuthenticationKind.Typed, "the typed-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 DeleteSelectedKeyAsync(vault); 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 keychain"); } [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 DeleteSelectedKeyAsync(vault); 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.EditorSelectedAuthentication.ShouldNotBeNull(); selected.EntityId.ShouldBe(keyId); selected.Kind.ShouldBe(AuthenticationKind.SshKey, "a missing key must not come back as a credential"); 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.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Kind is AuthenticationKind.Typed); 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(); } // ---- Stored credentials ---- [Fact] public async Task ACredentialRoundTripsThroughTheEditorAndTheVault() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy"); var row = vault.Credentials.ShouldHaveSingleItem(); row.Label.ShouldBe("prod deploy"); row.Description.ShouldBe("deploy", "the account is what the row has to show"); // Through the server and back, which is the whole point of storing it in the vault rather than on the // machine that typed it. await vault.SyncCommand.ExecuteAsync(null); await vault.LoadAsync(Token); vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret"); vault.SelectedCredential = vault.Credentials[0]; vault.EditSelectedCredentialCommand.Execute(null); vault.CredentialEditorPassword.ShouldBe( "s3cret", "the editor has to load it, because saving re-encodes every field"); vault.CredentialEditorUsername.ShouldBe("deploy"); } [Fact] public async Task ACredentialWithNoUsername_SaysItUsesTheHosts() { // Blank and absent are one state — CredentialSecret normalises them — and the row has to say which of // the two things a blank box means, because "no username" and "the host's username" are not the same // statement and only one of them is true. await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "shared password", username: " "); vault.Credentials.ShouldHaveSingleItem().Credential.Username.ShouldBeNull(); vault.Credentials[0].Description.ShouldBe("uses each host's own username"); } [Fact] public async Task ACredentialWithNoPassword_IsRefusedAndTheEditorStaysOpen() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewCredentialCommand.Execute(null); vault.CredentialEditorLabel = "prod deploy"; await vault.SaveCredentialCommand.ExecuteAsync(null); vault.Status.ShouldContain("password"); vault.Credentials.ShouldBeEmpty(); vault.IsEditingCredential.ShouldBeTrue("the editor stays open so it can be filled in"); } [Fact] public async Task CancellingTheCredentialEditor_LeavesNoPasswordBehindInIt() { // The same rule as the key editor, for the same reason: the editor holds the secret in a bound property // for as long as it is open, and an abandoned editor that kept it would hand it to whatever opened next. await UnlockedAsync(); var vault = shell.Vault!; vault.NewCredentialCommand.Execute(null); vault.CredentialEditorLabel = "prod deploy"; vault.CredentialEditorPassword = "ABANDONED"; vault.CancelCredentialEditCommand.Execute(null); vault.IsEditingCredential.ShouldBeFalse(); vault.CredentialEditorPassword.ShouldBeEmpty(); vault.CredentialEditorLabel.ShouldBeEmpty(); vault.Credentials.ShouldBeEmpty(); } [Fact] public async Task DeletingACredential_RemovesItLocallyAndPushesTheTombstone() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy"); vault.SelectedCredential = vault.Credentials[0]; await DeleteSelectedCredentialAsync(vault); vault.Credentials.ShouldBeEmpty(); server.LiveRowCount.ShouldBe(0); vault.PendingChanges.ShouldBe(0); } /// /// A reload must not invent a selection, which is a safety property rather than a tidiness one: /// Delete acts on the selection, so a list that fell back to its first row would put a one-click deletion of /// somebody's password behind a button they never aimed. It does keep an existing selection, exactly as the /// host list does — losing it on every background sync would move the target out from under the user. /// [Fact] public async Task ReloadingKeepsACredentialSelectionButNeverInventsOne() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "prod deploy"); // Saving selects what was just saved, which is wanted, and a reload has to leave it alone. vault.SelectedCredential.ShouldNotBeNull(); await vault.LoadAsync(Token); vault.SelectedCredential.ShouldNotBeNull(); // Nothing selected is the state Delete must find nothing in. vault.SelectedCredential = null; await vault.LoadAsync(Token); vault.SelectedCredential.ShouldBeNull(); // Explicitly rather than through the helper: with nothing selected there is nothing to ask about, // and the absence of a question is what proves the button found nothing to aim at. vault.DeleteCredentialCommand.Execute(null); vault.PendingDeletion.ShouldBeNull(); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Credentials.ShouldHaveSingleItem(); } [Fact] public async Task TheVaultSummaryCountsCredentialsToo() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "prod deploy"); await vault.LoadAsync(Token); vault.Status.ShouldBe("1 host(s), 1 key(s), 1 credential(s) in Personal."); // And a kind with nothing in it is left out rather than reported as zero. vault.SelectedCredential = vault.Credentials[0]; await DeleteSelectedCredentialAsync(vault); await vault.LoadAsync(Token); vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal."); } /// /// What decides whether the terminal column shows a password box at all. Three states and only one of them /// wants typing, so this is the property that keeps a box from appearing on a host that has no use for one /// — and keeps the sentence in its place from claiming the wrong reason. /// [Fact] public async Task ThePasswordBoxOnlyAppearsForAHostThatWillAskForOne() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "prod deploy"); vault.SelectedHost = vault.Hosts[0]; vault.SelectedHostAsksForAPassword.ShouldBeTrue(); vault.SelectedHostAuthenticationNote.ShouldBeEmpty(); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); vault.SelectedHost = vault.Hosts[0]; vault.SelectedHostAsksForAPassword.ShouldBeFalse(); vault.SelectedHostAuthenticationNote.ShouldContain("SSH key"); await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); vault.SelectedHost = vault.Hosts[0]; vault.SelectedHostAsksForAPassword.ShouldBeFalse(); vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain"); } // ---- Authenticating with a stored credential ---- [Fact] public async Task AHostBoundToACredential_SendsItsPasswordAndItsUsername() { // Both halves, and the username is the half that was easy to lose: a credential's whole reason for // existing is that it describes an account once, and sending its password under the host's username is // wrong in a way the server only reports as "authentication failed". // // The credential's username is deliberately *not* the host's — the host is on "deploy" — because the // two being equal is what makes this assertion pass under an implementation that reads the wrong one. var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "svc-deploy"); var credentialId = vault.Credentials[0].EntityId; await BindCredentialAsync(vault, vault.Hosts[0], credentialId); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credentialId); vault.Hosts[0].Authentication.ShouldBe("credential"); vault.ConnectPassword = "should-not-be-used"; await ConnectWithRendererAsync(vault); var request = ssh.Requests.ShouldHaveSingleItem(); request.Credential.ShouldBeOfType().Password.ShouldBe("s3cret"); request.Username.ShouldBe("svc-deploy", "the credential's account overrides the host's"); } [Fact] public async Task ACredentialWithNoUsername_LeavesTheHostsInPlace() { // The shared-password case: one password used under whatever account each machine knows you by. The // fallback is what makes that expressible at all, and getting it backwards would send every connection // to the same account. var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "shared password", password: "s3cret"); await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); await ConnectWithRendererAsync(vault); var request = ssh.Requests.ShouldHaveSingleItem(); request.Username.ShouldBe("deploy", "the host's own username, which ReadyToConnectAsync sets"); request.Credential.ShouldBeOfType().Password.ShouldBe("s3cret"); } [Fact] public async Task AHostWithNoUsernameOfItsOwn_IsUsableThroughACredentialThatCarriesOne() { // The refusal for a host with no username used to run before anything looked at the binding, which made // a credential's username unreachable in exactly the case it was most useful: a host somebody never // filled a username in for. var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorUsername = string.Empty; await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem().Host.Username.ShouldBeNull(); await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy"); await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); await ConnectWithRendererAsync(vault); ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy"); } [Fact] public async Task AHostWithNoUsernameAndNoCredential_IsStillRefusedAndSaysWhereToPutOne() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); vault.EditorUsername = string.Empty; await vault.SaveHostCommand.ExecuteAsync(null); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldBeEmpty(); vault.Status.ShouldContain("no username"); vault.Status.ShouldContain("credential", Case.Insensitive); } [Fact] public async Task AHostWhoseCredentialHasBeenDeleted_RefusesRatherThanFallingBackToTheTypedPassword() { // The key case's twin, and it has to be its own test: the two branches are separate code, and the one // that was written second is the one nothing would have covered. var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "prod deploy"); await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); vault.SelectedCredential = vault.Credentials[0]; await DeleteSelectedCredentialAsync(vault); vault.Credentials.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("credential that is not in this keychain"); } [Fact] public async Task EditingAHostWhoseCredentialHasBeenDeleted_DoesNotQuietlyUnbindIt() { var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "prod deploy"); var credentialId = vault.Credentials[0].EntityId; await BindCredentialAsync(vault, vault.Hosts[0], credentialId); vault.SelectedCredential = vault.Credentials[0]; await DeleteSelectedCredentialAsync(vault); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull(); selected.EntityId.ShouldBe(credentialId); selected.Kind.ShouldBe( AuthenticationKind.Credential, "a missing credential must not come back as a missing key"); 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.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding"); } // ---- Remembering a typed password ---- [Fact] public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain() { var vault = await ReadyToConnectAsync(); vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision"); vault.ConnectPassword = "s3cret"; vault.RemembersConnectPassword = true; await ConnectAndRememberAsync(vault); // An ordinary keychain credential, named after the host, and carrying no username of its own — the // connection that just succeeded used the host's, and pinning a copy of it here would stop following // the host. var stored = vault.Credentials.ShouldHaveSingleItem(); stored.Label.ShouldBe("prod-db"); stored.Credential.Password.ShouldBe("s3cret"); stored.Credential.Username.ShouldBeNull(); var host = vault.Hosts.ShouldHaveSingleItem(); host.Host.CredentialId.ShouldBe(stored.EntityId); host.Authentication.ShouldBe("credential"); // The box has nothing left to hold and nothing left to ask, and the tick does not carry over to // whatever host is selected next. vault.ConnectPassword.ShouldBeEmpty(); vault.RemembersConnectPassword.ShouldBeFalse(); vault.SelectedHostAsksForAPassword.ShouldBeFalse(); } [Fact] public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection() { // The whole point of storing it in the vault rather than on this machine: it is a property of the // host that reaches the other machines, not a box this one happens to remember filling in. var vault = await ReadyToConnectAsync(); // One renderer for both connections. The page's token is spent on the first attach, so a second // FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason // nothing else in this suite connects twice. await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ConnectPassword = "s3cret"; vault.RemembersConnectPassword = true; await vault.ConnectCommand.ExecuteAsync(null); await vault.SyncCommand.ExecuteAsync(null); await vault.LoadAsync(Token); vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret"); vault.SelectedHost = vault.Hosts[0]; vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now"); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.Count.ShouldBe(2); ssh.Requests[1].Credential.ShouldBeOfType().Password.ShouldBe("s3cret"); } [Fact] public async Task ARefusedConnection_RemembersNothing() { // The failure this feature could most easily cause: a typo bound to the host, which then stops asking // and cannot be connected to until somebody works out that the keychain is where the wrong password // now lives. Only a handshake the remote accepted is worth keeping. var vault = await ReadyToConnectAsync(); ssh.Failure = new InvalidOperationException("authentication failed"); vault.ConnectPassword = "wrong"; vault.RemembersConnectPassword = true; await vault.ConnectCommand.ExecuteAsync(null); vault.Credentials.ShouldBeEmpty(); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(); vault.SelectedHostAsksForAPassword.ShouldBeTrue(); } [Fact] public async Task ConnectingWithoutTheTick_StoresNothing() { // The other half of the decision, and the reason the typed box still exists: a one-off password on a // machine somebody will never open again must not end up synchronised to every device they own. var vault = await ReadyToConnectAsync(); vault.ConnectPassword = "s3cret"; await ConnectWithRendererAsync(vault); vault.Credentials.ShouldBeEmpty(); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(); vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed"); } [Fact] public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword() { // A tick left over from a host that did ask must not manufacture a credential out of a stored one's // password — which is what reading the dialled secret without checking the binding would do. var vault = await ReadyToConnectAsync(); await AddCredentialAsync(vault, "prod deploy", password: "s3cret"); await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); vault.SelectedHost = vault.Hosts[0]; vault.RemembersConnectPassword = true; await ConnectWithRendererAsync(vault); vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain"); } /// /// The reason the picker is one control rather than two. HostSecret.TryValidate refuses a host naming /// both a key and a credential, so two pickers would have been able to express the state and would have had /// to reject it at save time; one picker cannot express it. This asserts the structural version of that — /// that rebinding replaces rather than accumulates. /// [Fact] public async Task RebindingFromAKeyToACredential_ReplacesTheBindingRatherThanAddingToIt() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy"); var keyId = vault.Keys[0].EntityId; var credentialId = vault.Credentials[0].EntityId; await BindKeyAsync(vault, vault.Hosts[0], keyId); vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId); await BindCredentialAsync(vault, vault.Hosts[0], credentialId); var host = vault.Hosts.ShouldHaveSingleItem().Host; host.CredentialId.ShouldBe(credentialId); host.SshKeyId.ShouldBeNull("one picker means one binding"); host.TryValidate(out _).ShouldBeTrue(); // And back again, which is the direction that would leave a stale credential behind. await BindKeyAsync(vault, vault.Hosts[0], keyId); vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(); vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId); } /// /// A key and a credential the user has named the same thing is the ordinary case, not a contrived one — a /// key called deploy and the deploy account's password. Without the qualifier the picker offers two /// identical rows that authenticate completely differently. /// [Fact] public async Task ThePickerDistinguishesAKeyAndACredentialWithTheSameName() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await AddCredentialAsync(vault, "deploy"); vault.SelectedHost = vault.Hosts[0]; vault.EditSelectedHostCommand.Execute(null); var named = vault.EditorAuthenticationChoices .Where(choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal)) .ToList(); named.Count.ShouldBe(2); named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]); } /// /// The credential editor guards the vault screen's own rail, exactly as the key editor does — but no /// longer the host editor, which is a different screen and has nothing to lose by the rail moving. /// [Fact] public async Task TheCredentialEditorGuardsTheKeychainScreensRail() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewCredentialCommand.Execute(null); vault.CredentialEditorPassword = "half-typed"; // The host editor opens freely: it is the Hosts screen's own business now. vault.NewHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue("a different screen's editor is not this one's to refuse"); vault.IsEditingCredential.ShouldBeTrue("and opening it must not have closed the credential editor"); vault.CredentialEditorPassword.ShouldBe("half-typed"); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsCredentials.ShouldBeTrue("the rail must not move away from an open vault-screen editor"); vault.CancelCredentialEditCommand.Execute(null); vault.ShowSectionCommand.Execute(VaultSection.All); vault.ShowsAll.ShouldBeTrue(); } // ---- Pinned host keys ---- /// /// The list that did not exist. Trust was created by the connect prompt and withdrawn from one host's /// editor, so a pin for a host that had since been deleted or re-addressed was unreachable from the /// interface entirely — it went on refusing connections, and nothing in the application would admit it /// was there. /// [Fact] public async Task ThePinnedKeyList_ShowsWhatWasApprovedAndWhatNothingUsesAnyMore() { var vault = await ReadyToConnectAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await knownHosts.TrustAsync( new HostKeyPresentation("gone.internal", 22, "ssh-ed25519", "SHA256:another-key"), Token); // Pushed, as the connect path pushes a pin the moment it is approved. Without this both rows would // be badged "not synced", which is true and would drown out the badge this test is about. await vault.SyncCommand.ExecuteAsync(null); await vault.LoadAsync(Token); vault.KnownHostPins.Count.ShouldBe(2); var dialled = vault.KnownHostPins .Single(pin => string.Equals(pin.Host, "db.internal", StringComparison.Ordinal)); dialled.IsDialledByAHost.ShouldBeTrue("ReadyToConnectAsync's host is at db.internal:22"); dialled.Fingerprint.ShouldBe("SHA256:the-key", "in full, because that is what gets compared"); dialled.Badge.ShouldBeEmpty(); var orphan = vault.KnownHostPins .Single(pin => string.Equals(pin.Host, "gone.internal", StringComparison.Ordinal)); orphan.IsDialledByAHost.ShouldBeFalse(); orphan.Badge.ShouldBe("no host uses this"); } [Fact] public async Task DeletingAHost_LeavesItsPinBehindAndTheListSaysSo() { // The behaviour the debt was about, now visible instead of silent. Keeping the pin is right — the // address may still be reached by something else, and trust is about the endpoint rather than the // bookmark — so the fix was never to cascade the delete. It was to stop the leftover being invisible. var vault = await ReadyToConnectAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await vault.LoadAsync(Token); vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue(); vault.SelectedHost = vault.Hosts[0]; await DeleteSelectedHostAsync(vault); vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse( "the pin outlives the host, and the list has to admit it"); } [Fact] public async Task ForgettingAPinFromTheList_WithdrawsEveryKeyForThatAddress() { // One address, two algorithms, one decision. Somebody withdrawing trust from a machine has not // decided to keep trusting one of its keys — and a pin left behind would go on being offered at the // next handshake, which reads as a withdrawal that did not work. var vault = await ReadyToConnectAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-ed25519-key"), Token); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ecdsa-sha2-nistp256", "SHA256:the-ecdsa-key"), Token); await vault.LoadAsync(Token); vault.KnownHostPins.Count.ShouldBe(2); vault.SelectedKnownHost = vault.KnownHostPins[0]; await vault.ForgetPinCommand.ExecuteAsync(null); vault.KnownHostPins.ShouldBeEmpty(); vault.Status.ShouldContain("2 pinned key(s)"); // And it reached the vault, not just the snapshot: the next connection has to ask again. (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); // Pushed straight away, as trusting is — the other machines are the ones still refusing. vault.PendingChanges.ShouldBe(0); } [Fact] public async Task ForgettingWithNothingSelected_DoesNothing() { var vault = await ReadyToConnectAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await vault.LoadAsync(Token); vault.SelectedKnownHost.ShouldBeNull("loading must not select a pin, because Forget acts on it"); await vault.ForgetPinCommand.ExecuteAsync(null); vault.KnownHostPins.ShouldHaveSingleItem(); } /// /// v5c-3: fingerprints are public — operators publish theirs on purpose — so this is the one clipboard /// copy on this screen that needs no confirmation and no refusal, unlike a private key's own /// CopyPublicKeyCommand. In full, because a shortened fingerprint cannot be compared against what /// was published. /// [Fact] public async Task CopyingAPinsFingerprint_PutsTheFullFingerprintOnTheClipboard() { var vault = await ReadyToConnectAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await vault.LoadAsync(Token); vault.SelectedKnownHost = vault.KnownHostPins.ShouldHaveSingleItem(); await vault.CopyPinFingerprintCommand.ExecuteAsync(null); clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key"); } /// The v5c screen's own restyle over forwards the same command. [Fact] public async Task CopyingAPinsFingerprintThroughTheKnownHostsScreen_ReachesTheVault() { await UnlockedAsync(); var vault = shell.Vault!; await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await vault.LoadAsync(Token); var pins = shell.KnownHostsScreen.ShouldNotBeNull(); pins.Selected = pins.VisiblePins.ShouldHaveSingleItem(); await pins.CopyFingerprintCommand.ExecuteAsync(null); clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key"); } /// /// The v5c header's own back arrow, reached through the same onBack delegate ImportViewModel's Cancel /// button uses — see MainWindowViewModel.OnVaultChanged. Its destination is the Keychain screen this list /// was pulled out of. /// [Fact] public async Task TheKnownHostsScreensBackArrow_ReturnsToKeychain() { await UnlockedAsync(); shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts); shell.IsKnownHostsScreen.ShouldBeTrue(); var pins = shell.KnownHostsScreen.ShouldNotBeNull(); pins.BackCommand.Execute(null); shell.IsKeychainScreen.ShouldBeTrue(); } /// /// Pins used to be a category on the keychain screen. They are a destination of their own now, and this /// is the seam that could silently come apart: the screen's view model is built from the vault in /// OnVaultChanged, so a vault opened by any path other than the one this test takes would leave /// the nav rail pointing at a null. /// [Fact] public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes() { await UnlockedAsync(); var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it"); shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts); shell.IsKnownHostsShowing.ShouldBeTrue(); shell.IsKeychainShowing.ShouldBeFalse(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await shell.Vault!.LoadAsync(Token); pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal"); await shell.LockCommand.ExecuteAsync(null); shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from"); } /// /// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know /// whether it is the one they approved. A filter that searched only host names would answer a different /// question, so this is the assertion that keeps the fingerprint in the search. /// [Fact] public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost() { await UnlockedAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token); await knownHosts.TrustAsync( new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token); await shell.Vault!.LoadAsync(Token); var pins = shell.KnownHostsScreen!; pins.VisiblePins.Count.ShouldBe(2); pins.Filter = "bbbb"; pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal"); pins.Filter = "db."; pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal"); pins.Filter = "nothing matches this"; pins.VisiblePins.ShouldBeEmpty(); pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive); } /// /// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push. /// What this covers is the forwarding: that the screen's own selection reaches it. /// [Fact] public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust() { await UnlockedAsync(); await knownHosts.TrustAsync( new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); await shell.Vault!.LoadAsync(Token); var pins = shell.KnownHostsScreen!; pins.Selected = pins.VisiblePins[0]; await pins.ForgetSelectedCommand.ExecuteAsync(null); pins.VisiblePins.ShouldBeEmpty(); (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull(); } // ---- Generating a key ---- /// /// The property that keeps this feature from being a second way to write a key: generating fills the /// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a /// pasted key already takes, and SAVE is still the only thing that writes. /// [Fact] public async Task GeneratingAKey_FillsTheEditorAndStoresNothing() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewGeneratedKeyCommand.Execute(null); vault.IsGeneratingKey.ShouldBeTrue(); vault.GenerateComment = "deploy@laptop"; await vault.GenerateKeyCommand.ExecuteAsync(null); vault.IsGeneratingKey.ShouldBeFalse(); vault.IsEditingKey.ShouldBeTrue("what it made lands in the editor, unsaved"); vault.KeyEditorLabel.ShouldBe("deploy@laptop"); vault.KeyEditorPrivateKey.ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----"); vault.KeyEditorPublicKey.ShouldStartWith("ssh-ed25519 "); vault.KeyEditorPublicKey.ShouldEndWith("deploy@laptop"); vault.Keys.ShouldBeEmpty("nothing is stored until SAVE"); vault.PendingChanges.ShouldBe(0); vault.Status.ShouldContain("SAVE"); // And then it saves through the ordinary path, which is the other half of the claim. await vault.SaveKeyCommand.ExecuteAsync(null); vault.Keys.ShouldHaveSingleItem().Label.ShouldBe("deploy@laptop"); } [Fact] public async Task CancellingTheGenerateForm_MakesNothing() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewGeneratedKeyCommand.Execute(null); vault.CancelGenerateKeyCommand.Execute(null); vault.IsGeneratingKey.ShouldBeFalse(); vault.IsEditingKey.ShouldBeFalse(); vault.Keys.ShouldBeEmpty(); } /// /// The public half and only the public half. There is deliberately no command for the other one — a /// private key on a clipboard is a private key in every application on the machine — so what this pins /// is that the one thing installing a key needs does reach the clipboard. /// [Fact] public async Task CopyingAPublicKey_PutsThePublicHalfOnTheClipboard() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewGeneratedKeyCommand.Execute(null); await vault.GenerateKeyCommand.ExecuteAsync(null); await vault.SaveKeyCommand.ExecuteAsync(null); vault.Section = VaultSection.Keys; vault.SelectedVaultItem = vault.VaultItems[0]; vault.SelectedItemIsKey.ShouldBeTrue(); await vault.CopyPublicKeyCommand.ExecuteAsync(null); var copied = clipboard.ShouldHaveSingleItem(); copied.ShouldStartWith("ssh-"); copied.ShouldNotContain("PRIVATE KEY"); } /// /// ◆ The one secret this application deliberately offers to the clipboard. The recovery code /// exists for one screen, is stored nowhere and has to reach a password manager, so the clipboard is /// where it is going whatever the interface does — the only question is whether the interface helps or /// leaves somebody transcribing it, or photographing a screen that blocks screenshots. /// [Fact] public async Task CopyingTheRecoveryCode_PutsItOnTheClipboardAndSaysWhereToPutIt() { await EnrolledAsync(); shell.State.ShouldBe(ShellState.ShowingRecoveryCode); var code = shell.RecoveryCode.ShouldNotBeNull(); await shell.CopyRecoveryCodeCommand.ExecuteAsync(null); clipboard.ShouldHaveSingleItem().ShouldBe(code); // The sentence matters as much as the copy. A clipboard is a staging post rather than a home, and // this screen is the only place the code exists — somebody who copies it and does nothing has not // saved it. shell.StatusMessage.ShouldContain("password manager"); shell.StatusMessage.ShouldContain("replaces it"); } /// /// The other branch, and it needs a shell built without a clipboard because that is precisely the /// condition — the view model reads the delegate's absence, not an empty result. It must say so rather /// than leaving a button that appears to have worked: a recovery code somebody believes is on their /// clipboard is a recovery code they will not write down. /// [Fact] public async Task CopyingTheRecoveryCode_WithNoClipboard_SaysSoRatherThanSeemingToWork() { var bare = new MainWindowViewModel( paths, caches, workspace, knownHosts, deviceKeys, SignInAsync, TimeProvider.System, ssh, CheapProfile, ResumeAsync); await using (bare.ConfigureAwait(false)) { bare.RecoveryCode = "correct horse battery staple"; await bare.CopyRecoveryCodeCommand.ExecuteAsync(null); bare.StatusMessage.ShouldContain("no clipboard", Case.Insensitive); clipboard.ShouldBeEmpty("the other shell's clipboard must not have been written to either"); } } // ---- Importing ssh_config ---- /// /// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the /// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing /// goes through the ordinary create-and-push path, and that a host already in the keychain arrives /// unticked rather than being silently duplicated. /// [Fact] public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); var sshDirectory = Path.Combine(directory, "ssh"); Directory.CreateDirectory(sshDirectory); // db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held. await File.WriteAllTextAsync( Path.Combine(sshDirectory, "config"), """ Host already-here HostName db.internal User deploy Host web-01 HostName web-01.internal User deploy Port 2222 """, Token); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.Rows.Count.ShouldBe(2); vault.Hosts.Count.ShouldBe(1, "scanning stores nothing"); var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal)); known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has"); known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default"); import.Rows .Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal)) .IsSelected.ShouldBeTrue(); await import.ImportCommand.ExecuteAsync(null); var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal)); imported.Address.ShouldBe("deploy@web-01.internal:2222"); vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored"); // Through the ordinary path, which is the point of routing it through the vault: it reached the // server without anything pressing Sync. server.LiveRowCount.ShouldBe(2); } /// /// ◆ The tick, and the half of it that matters is what happens with it off. Reading a private key /// out of a home directory is the act this product exists to make deliberate, so the default has to be a /// default nobody arrives at by accident — and a scan that read keys in order to describe them would /// have already done the thing the tick gates. So this asserts twice over: nothing is bound, and the /// report that names every file it opened is empty. /// [Fact] public async Task ImportingWithoutTheKeyTick_RecordsThePathAndReadsNothing() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.HasKeyFiles.ShouldBeTrue("the tick is drawn only where there is a key to read"); import.ImportsKeys.ShouldBeFalse("and it starts off"); await import.ImportCommand.ExecuteAsync(null); vault.Keys.ShouldBeEmpty("nothing reached into ~/.ssh"); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull(); import.KeyReport.ShouldBeEmpty("and no file was opened to report on"); // The path is still recorded, which is what the import did before the tick existed. vault.Hosts[0].Host.Notes.ShouldContain("id_ed25519"); } /// /// The other side of it: with the tick on, the key is read, stored encrypted in the vault and bound to /// the host — which is the difference between an import whose result connects and one whose every host /// asks for a password. /// [Fact] public async Task ImportingWithTheKeyTick_StoresTheKeyAndBindsTheHostToIt() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.ImportsKeys = true; // The row says what it will do, which is the only place a person sees the tick's effect per host. import.Rows.ShouldHaveSingleItem().Authentication.ShouldContain("imported"); await import.ImportCommand.ExecuteAsync(null); var key = vault.Keys.ShouldHaveSingleItem(); key.Key.PrivateKeyPem.ShouldContain("PRIVATE KEY"); key.Key.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example"); key.Key.Notes.ShouldContain("id_ed25519"); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(key.EntityId); vault.Hosts[0].Authentication.ShouldBe("key"); import.KeyReport.ShouldHaveSingleItem().ShouldContain("imported"); import.Status.ShouldContain("1 private key"); } /// /// /// One vault key per file, however many entries named it — the shape a real ssh_config actually /// has. Twelve copies of one private key would be twelve things to rotate and eleven to forget, which is /// the argument HostSecret.SshKeyId already makes for referencing a key rather than embedding it. /// /// /// The second half is the one that would go unnoticed: importing the same config twice must bind to the /// key that is already there rather than storing a second copy of it. Asserted by running the whole /// import again over a keychain that now holds one. /// /// [Fact] public async Task TwoHostsNamingOneKeyFile_ShareOneStoredKeyAndARepeatAddsNone() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(""" Host web-01 HostName web-01.internal User deploy IdentityFile ~KEY~ Host web-02 HostName web-02.internal User deploy IdentityFile ~KEY~ """); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.ImportsKeys = true; await import.ImportCommand.ExecuteAsync(null); var key = vault.Keys.ShouldHaveSingleItem("one file is one key"); vault.Hosts.Count.ShouldBe(2); vault.Hosts.ShouldAllBe(row => row.Host.SshKeyId == key.EntityId); import.Status.ShouldContain("1 private key"); // Again, over a keychain that already holds it. The hosts duplicate — a second bookmark for one // machine is allowed and takes a click — and the key must not. foreach (var row in import.Rows) { row.IsSelected = true; } await import.ImportCommand.ExecuteAsync(null); vault.Keys.ShouldHaveSingleItem("the material was already here, so it was bound to rather than stored"); import.Status.ShouldNotContain("private key", Case.Insensitive); } /// /// A passphrase is the one thing about a key file that is not in the key file, so an encrypted one comes /// in without it and would fail at connect time with SSH.NET's own message. The import says so instead — /// naming the file, because a config with a dozen keys has a dozen candidates for which one it was. /// [Fact] public async Task AnEncryptedKeyIsImportedAndSaidToBeEncrypted() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(); await File.WriteAllTextAsync( Path.Combine(sshDirectory, "id_ed25519"), "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n" + "-----END RSA PRIVATE KEY-----\n", Token); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.ImportsKeys = true; await import.ImportCommand.ExecuteAsync(null); vault.Keys.ShouldHaveSingleItem().Key.Passphrase.ShouldBeNull("nothing on disk says what it is"); var reported = import.KeyReport.ShouldHaveSingleItem(); reported.ShouldContain("passphrase"); reported.ShouldContain("id_ed25519"); } /// /// A config carried from another machine names keys that are not on this one, and that is the ordinary /// case rather than an error. The host still imports — unbound, exactly as it would have without the /// tick — and the report says which file was missing, because otherwise the only symptom is a host that /// asks for a password on a screen that said it would not. /// [Fact] public async Task AKeyFileThatIsNotThere_LeavesTheHostImportedAndUnbound() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(); File.Delete(Path.Combine(sshDirectory, "id_ed25519")); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.ImportsKeys = true; await import.ImportCommand.ExecuteAsync(null); vault.Keys.ShouldBeEmpty(); vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull(); import.KeyReport.ShouldHaveSingleItem().ShouldContain("no such file"); } /// /// A ~/.ssh holding a config that names a key, and the key beside it. /// /// /// The key path is written into the config as an absolute one, which is what /// SshConfigResolver.ExpandHome produces for a real ~/.ssh/id_ed25519 — the tests cannot /// use a tilde, since that would resolve against the profile of whoever is running them. /// private string KeyedConfigDirectory(string? config = null) { var sshDirectory = Path.Combine(directory, $"ssh-{Guid.CreateVersion7():N}"); Directory.CreateDirectory(sshDirectory); var keyPath = Path.Combine(sshDirectory, "id_ed25519"); File.WriteAllText(keyPath, PrivateKey("QUJDRA")); File.WriteAllText(keyPath + ".pub", "ssh-ed25519 AAAAC3Nz nobody@example\n"); var text = config ?? """ Host web-01 HostName web-01.internal User deploy IdentityFile ~KEY~ """; File.WriteAllText( Path.Combine(sshDirectory, "config"), text.Replace("~KEY~", keyPath, StringComparison.Ordinal)); return sshDirectory; } [Fact] public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing() { await UnlockedAsync(); var import = new ImportViewModel( shell.Vault!, new SshConfigLocator(Path.Combine(directory, "nothing-here"))); await import.ScanCommand.ExecuteAsync(null); import.Rows.ShouldBeEmpty(); import.Status.ShouldContain("no", Case.Insensitive); } // ---- v5c-3: the WHAT THIS MEANS chip, tick-all, and the footer's own facts ---- /// /// The three real states a row can be in, and nothing else: a skipped Host pattern never becomes a /// row at all (see SshConfigImport.SkippedPatterns), so there is no fourth, invented "skipped" chip /// to test for. A warned row wins over "already here" — see ImportRowViewModel.Meaning. /// [Fact] public async Task TheImportersMeaningChipsMapTheRealRowStatesHonestly() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); var sshDirectory = Path.Combine(directory, $"ssh-meaning-{Guid.CreateVersion7():N}"); Directory.CreateDirectory(sshDirectory); await File.WriteAllTextAsync( Path.Combine(sshDirectory, "config"), """ Host already-here HostName db.internal User deploy Host bastion HostName bastion.internal User ops ProxyCommand nc %h %p Host fresh HostName fresh.internal User deploy """, Token); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.Rows.Count.ShouldBe(3); var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal)); known.IsMeaningExisting.ShouldBeTrue(); known.IsMeaningNew.ShouldBeFalse(); known.IsMeaningWarned.ShouldBeFalse(); known.Meaning.ShouldBe("already here"); var warned = import.Rows.Single(row => string.Equals(row.Alias, "bastion", StringComparison.Ordinal)); warned.IsMeaningWarned.ShouldBeTrue(); warned.IsMeaningNew.ShouldBeFalse(); warned.IsMeaningExisting.ShouldBeFalse(); // The warned chip carries the row's own real reason. warned.Meaning.ShouldContain("ProxyCommand"); var fresh = import.Rows.Single(row => string.Equals(row.Alias, "fresh", StringComparison.Ordinal)); fresh.IsMeaningNew.ShouldBeTrue(); fresh.IsMeaningExisting.ShouldBeFalse(); fresh.IsMeaningWarned.ShouldBeFalse(); fresh.Meaning.ShouldBe("new host"); } /// The header's own tick-all box, over . [Fact] public async Task TickingAllTogglesEveryRowAndTheHeaderTickReflectsIt() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = Path.Combine(directory, $"ssh-tickall-{Guid.CreateVersion7():N}"); Directory.CreateDirectory(sshDirectory); await File.WriteAllTextAsync( Path.Combine(sshDirectory, "config"), """ Host a HostName a.internal Host b HostName b.internal """, Token); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.AllTicked.ShouldBeTrue("both are new hosts, which start ticked"); import.Rows[0].IsSelected = false; import.NoteSelectionChanged(); import.AllTicked.ShouldBeFalse(); import.ToggleAllCommand.Execute(null); import.AllTicked.ShouldBeTrue("fewer than all ticked toggles everything on"); import.Rows.ShouldAllBe(row => row.IsSelected); import.ToggleAllCommand.Execute(null); import.AllTicked.ShouldBeFalse(); import.Rows.ShouldAllBe(row => !row.IsSelected); } /// /// The key-material opt-in card's own always-visible sentence: a real count of hosts naming a key file, /// the real directory, and the same "nothing is read until Import is pressed" claim verified against /// only ever being called from ImportAsync. /// [Fact] public async Task TheKeyMaterialCardsIntroSentence_NamesTheRealCountAndDirectory() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = KeyedConfigDirectory(); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.KeyMaterialIntro.ShouldContain("1 host names"); import.KeyMaterialIntro.ShouldContain(sshDirectory); import.KeyMaterialIntro.ShouldContain( "nothing is read until Import is pressed", Case.Insensitive); } [Fact] public async Task TheFooterSummary_NamesTheRealSelectionCountAndVault() { await UnlockedAsync(); var vault = shell.Vault!; var sshDirectory = Path.Combine(directory, $"ssh-summary-{Guid.CreateVersion7():N}"); Directory.CreateDirectory(sshDirectory); await File.WriteAllTextAsync( Path.Combine(sshDirectory, "config"), "Host a\n HostName a.internal\n", Token); var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory)); await import.ScanCommand.ExecuteAsync(null); import.SelectionSummary.ShouldBe($"1 of 1 entry selected · saving to {vault.VaultName}"); } // ---- Filtering the host sidebar ---- /// /// The filter's own list is a projection over , not the bound source /// of the sidebar's selection — but HostSidebar's ListBox two-way binds /// SelectedItem to against exactly that projection, so /// a naive rebuild that cleared the list before refilling it would have the list null the selection out /// from under the user on every keystroke, even when the filter still matches the selected host. /// [Fact] public async Task FilteringTheHostListPreservesTheSelectionWhenItStillMatches() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-web-01"); await AddHostAsync(vault, "prod-web-02"); vault.SelectedHost = vault.Hosts.Single( host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal)); vault.HostFilter = "prod"; vault.VisibleHosts.Count.ShouldBe(2, "both hosts match the filter"); vault.SelectedHost.ShouldNotBeNull(); vault.SelectedHost!.Label.ShouldBe("prod-web-01", "a filter that still matches must not clear it"); vault.HostFilter = "web-02"; vault.VisibleHosts.ShouldHaveSingleItem(); vault.SelectedHost.ShouldBeNull("the selected host no longer matches, so there is nothing to keep"); vault.HostFilter = string.Empty; vault.VisibleHosts.Count.ShouldBe(2); } /// /// The same hazard as the filter, reached through the other caller of the rebuild: a background sync /// pass reloads the host list every minute, and ReloadHostsAsync deliberately restores the /// selection before handing off to the sidebar's projection. Losing it there would be exactly the "move /// the terminal's target out from under the user" outcome that restoration exists to prevent. /// [Fact] public async Task ReloadingTheVaultPreservesTheSidebarsSelection() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); vault.SelectedHost = vault.Hosts.Single( host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal)); await vault.LoadAsync(Token); vault.SelectedHost.ShouldNotBeNull(); vault.SelectedHost!.Label.ShouldBe("stage-web"); vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost)); } // ---- Groups ---- /// /// The property that makes this feature free to ignore. Somebody with eleven machines and no wish to file /// them should see the list they have always seen — not a heading telling them their hosts are ungrouped. /// [Fact] public async Task AVaultWithNoGroups_DrawsNoHeadings() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); vault.HasGroups.ShouldBeFalse(); vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel); vault.SidebarRows.Count.ShouldBe(vault.VisibleHosts.Count); } [Fact] public async Task FilingAHostIntoAGroup_PutsItUnderThatHeading() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var rows = vault.SidebarRows.ToArray(); // One group, so: its heading, its one host, then the ungrouped heading and the other host. rows[0].ShouldBeOfType().Label.ShouldBe("production"); rows[1].ShouldBeOfType().Label.ShouldBe("prod-db"); rows[2].ShouldBeOfType().Label.ShouldBe("UNGROUPED"); rows[3].ShouldBeOfType().Label.ShouldBe("stage-web"); } /// /// v5c-2: the settings Groups page's "No group" footer row. Counts a host whose group has never been set /// and one whose group id dangles (deleted from under it) the same way — both are "ungrouped" to a person /// looking at the list, per the reading FlattenIntoSections already gives the sidebar's own /// heading, and UngroupedHostCount has to agree with it rather than invent a second definition. /// [Fact] public async Task UngroupedHostCount_CountsHostsWithNoGroupAndHostsWhoseGroupHasGone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); await AddHostAsync(vault, "bastion"); await AddGroupAsync(vault, "production"); vault.UngroupedHostCount.ShouldBe(3, "no host has been filed under the new group yet"); await FileAsync(vault, "prod-db", "production"); vault.UngroupedHostCount.ShouldBe(2, "one host now belongs to a real group"); var group = vault.Groups.Single(); vault.DeleteGroupCommand.Execute(group); vault.PendingDeletion.ShouldNotBeNull(); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.UngroupedHostCount.ShouldBe( 3, "a host whose group was deleted falls back to ungrouped rather than vanishing from the count"); } /// /// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder /// somebody made and can put things in, the second is an absence of search results — and a heading with /// nothing under it reads as a group that has lost its contents. /// [Fact] public async Task AGroupEmptiedByTheFilter_LosesItsHeading() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await AddGroupAsync(vault, "staging"); await FileAsync(vault, "prod-db", "production"); Headings(vault).ShouldBe(["production", "staging"], "an empty group keeps its heading"); vault.HostFilter = "nothing matches this"; Headings(vault).ShouldBe(["production", "staging"]); vault.SidebarRows.OfType().ShouldBeEmpty(); } [Fact] public async Task FoldingAGroupAwayHidesItsHostsAndSurvivesAReload() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType().First()); vault.SidebarRows.OfType().ShouldBeEmpty("the group is folded away"); // Folded state is held by group id rather than on the row, because a background sync rebuilds every // row once a minute and a flag on one would be forgotten the first time it did. await vault.LoadAsync(Token); vault.SidebarRows.OfType().ShouldBeEmpty("and a reload does not unfold it"); } /// /// The heading is a row in the same ListBox as the hosts, so the control will select it. Nothing /// else in the application acts on a heading — CONNECT, EDIT and DELETE all read the host selection — so /// clicking one has to leave that selection exactly where it was. /// [Fact] public async Task SelectingAHeading_LeavesTheHostSelectionAlone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var host = vault.Hosts.Single(); vault.SelectedHost = host; vault.SelectedSidebarRow = vault.SidebarRows.OfType().First(); vault.SelectedHost.ShouldBeSameAs(host); vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back"); } /// /// /// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see /// — which is the one thing every head can still do. What survives to measure is /// and 's own /// "one level of the tree" filtering, fed by directly now that /// nothing sets it through a command — see that property's own remarks. /// /// /// The card goes into the group and off the level it was filed from, which is the whole of what /// filing looks like on a grid that holds one level of the tree — the host is inside the card it was /// filed under now, and that is where it is drawn. It used to stay put and gain a chip. /// /// [Fact] public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); var group = vault.Groups.Single().EntityId; await FileAsync(vault, "prod-db", "production"); vault.Hosts.Single().Host.GroupId.ShouldBe(group); vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group"); // Under the group's own heading now, which is what the phone's list draws — that list is the whole // tree flattened, so the host is still in it. vault.SidebarRows.OfType() .Single(header => header.GroupId == group) .Count.ShouldBe(1); // The name is on the card, which is what the desktop's grid draws instead of that heading, and what // says which group a searched-up card came out of. vault.Hosts.Single().GroupLabel.ShouldBe("production"); vault.Hosts.Single().HasGroup.ShouldBeTrue(); // Opening the group is where it went, and the way to it. vault.GroupFilter = vault.Groups.Single(); vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); // And back out again. vault.GroupFilter = null; vault.SelectedHost = vault.Hosts.Single(); vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedGroup = vault.EditorGroupChoices.Single( choice => choice.EntityId is null); await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.Single().Host.GroupId.ShouldBeNull(); vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it"); vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); } /// /// The grid holds one level of the tree, the way a directory pane holds one directory. /// /// /// A host filed under a group is inside that group and is not also on the screen the group's card sits /// on. While it was both, opening a group could only ever take hosts away — the level above already had /// all of them — and the cards were headings rather than places. The phone's list is the deliberate /// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has /// no cards to open and nowhere to open one into. /// [Fact] public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.VisibleHosts.Select(row => row.Label) .ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into"); vault.SidebarRows.OfType().Select(row => row.Label) .ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened"); vault.GroupFilter = vault.Groups.Single(); vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]); vault.GroupFilter = null; vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]); } /// /// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked /// only at the level it was typed on would answer "no host matches that" about a machine this keychain /// has got — and finding a machine without first remembering where it was filed is most of what the box /// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that /// group and what is under it, which is the same rule read from where you are standing. /// [Fact] public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); await AddGroupAsync(vault, "estate"); await AddGroupAsync(vault, "production"); await SetGroupParentAsync(vault, "production", "estate"); await FileAsync(vault, "prod-db", "production"); vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal)); vault.HostFilter = "prod-db"; vault.VisibleHosts.ShouldHaveSingleItem().Label .ShouldBe("prod-db", "two levels down, and the box reaches it"); // And inside a group it is that group's subtree: estate holds production, which holds the host. vault.GroupFilter = vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)); vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); vault.HostFilter = "stage"; vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it"); vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it"); } /// /// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from /// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid /// made reachable: before it, a keychain with hosts in it always drew some. /// [Fact] public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.HasVisibleHosts.ShouldBeFalse(); vault.NoVisibleHostsMessage.ShouldContain("filed under a group"); vault.NoVisibleHostsMessage.ShouldNotContain( "No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing"); } /// /// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created /// inside a group and filed under none would vanish from the screen it was created on, which is the /// papercut that comes free with a grid holding one level — so the editor opens on the group /// names, and the picker shows it before anything is saved. /// GroupFilter is written directly rather than through the deleted OpenGroupCommand — see /// that property's own remarks. /// [Fact] public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddGroupAsync(vault, "production"); vault.GroupFilter = vault.Groups.Single(); vault.NewHostCommand.Execute(null); vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production"); vault.EditorLabel = "prod-db"; vault.EditorHostname = "db.internal"; await vault.SaveHostCommand.ExecuteAsync(null); vault.VisibleHosts.ShouldHaveSingleItem().Label .ShouldBe("prod-db", "so it is on the screen it was made on"); vault.NewGroupCommand.Execute(null); vault.GroupEditorSelectedParent.ShouldNotBeNull().Label .ShouldBe("production", "+ NEW GROUP inside a group makes one inside it"); } /// /// /// Deleting a group leaves the machines under it alone and stops them naming it. It used to do /// only the first: the reference was left dangling and the list resolved it to nothing, which looked /// identical and cost no writes. The tick is what changed that — a deletion that can take the hosts with /// it has to be a deletion that knows which hosts it means, and once it knows, leaving them holding the /// id of something that has gone is a state kept for no reason. /// /// /// The tick is deliberately not touched here, which is the point of the assertions: the default answer /// is the one that keeps the machines. /// /// [Fact] public async Task DeletingAGroup_UnfilesItsHostsRatherThanLeavingThemNamingIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.DeleteGroupCommand.Execute(vault.Groups.Single()); var question = vault.PendingDeletion.ShouldNotBeNull(); question.Usage .ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading"); question.HasChoice.ShouldBeTrue("a group with a host under it has a second question"); vault.DeletionTakesTheHostsToo.ShouldBeFalse("the safe answer is the one nobody has to choose"); // The pass that follows every write on this screen reports what it moved and supersedes the // confirmation, for a deletion as much as for a save — so it is made to fail, and what the sentence // says is asserted in the state where somebody actually reads it. server.SyncFailure = new HttpRequestException("The server is having a bad day."); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Groups.ShouldBeEmpty(); vault.HasGroups.ShouldBeFalse(); vault.Hosts.Single().Host.GroupId.ShouldBeNull(vault.Status); vault.Hosts.Single().GroupLabel.ShouldBeEmpty(); vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel); vault.Status.ShouldContain("UNGROUPED", Case.Sensitive); } /// /// The other answer, and the reason the question is asked at all: a group is sometimes a heading being /// tidied away and sometimes a project that has been decommissioned, and nothing in the view model can /// tell which of the two it is looking at. /// [Fact] public async Task DeletingAGroupWithTheTickSet_TakesItsHostsWithIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.DeleteGroupCommand.Execute(vault.Groups.Single()); vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host"); vault.DeletionTakesTheHostsToo = true; await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Groups.ShouldBeEmpty(); // Only the machine that was filed under it. A deletion aimed at a heading must not reach the hosts // that were never on it. vault.Hosts.Select(row => row.Label).ShouldBe(["prod-web"], vault.Status); } /// /// The answer is not carried from one question to the next. A tick left standing would delete the next /// group's machines on the strength of a decision about the last one's, and there is no undo on either /// side of that. /// [Fact] public async Task AskingAboutASecondGroup_StartsFromKeepingItsHosts() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await AddGroupAsync(vault, "staging"); await FileAsync(vault, "prod-db", "production"); vault.DeleteGroupCommand.Execute(vault.Groups.Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal))); vault.DeletionTakesTheHostsToo = true; vault.CancelDeleteCommand.Execute(null); vault.DeleteGroupCommand.Execute(vault.Groups.Single( row => string.Equals(row.Label, "staging", StringComparison.Ordinal))); vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines"); } /// /// Unfiling rewrites every host under the heading, so it is refused with a host editor open for the /// reason a drop onto a group card is: rewriting the saved host under a half-typed edit of it would be a /// save nobody asked for, and one they could then not cancel. Deleting a single host is not refused, /// because that one writes nothing to a form anybody is looking at. /// [Fact] public async Task DeletingAGroupWhileTheHostEditorIsOpen_IsRefused() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = vault.Hosts.Single(); vault.EditSelectedHostCommand.Execute(null); vault.EditorLabel = "half-typed"; vault.DeleteGroupCommand.Execute(vault.Groups.Single()); vault.PendingDeletion.ShouldBeNull("the question was never put"); vault.IsEditing.ShouldBeTrue("and the edit is still there to finish"); vault.Status.ShouldContain("editing"); } /// /// /// The picker keeps a placeholder entry for a group the vault does not have, exactly as the /// authentication picker does for a deleted key. Without it the picker would open on "No group" and /// somebody editing the host's port would unfile it by saving. /// /// /// The dangling id is imported rather than produced by deleting the group, and that is a consequence of /// the change above rather than a contrivance: a group deleted here now unfiles its hosts on the /// way out, so the only way a host still names one is that the group went on another machine and this /// client has yet to be told — which is exactly what a host arriving with an id nothing resolves is. /// /// [Fact] public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving() { await UnlockedAsync(); var vault = shell.Vault!; var groupId = Guid.CreateVersion7(); await vault.ImportHostsAsync( [ new ImportedHostRequest( new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId }, Key: null, KeyPath: null), ], Token); vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id"); vault.SelectedHost = vault.Hosts.Single(); vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId); vault.EditorPort = 2222; await vault.SaveHostCommand.ExecuteAsync(null); vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host"); } [Fact] public async Task RenamingAGroup_RenamesItsHeading() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.EditGroupCommand.Execute(vault.Groups.Single()); vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box"); vault.GroupEditorLabel = "live"; await vault.SaveGroupCommand.ExecuteAsync(null); Headings(vault).ShouldBe(["live"]); vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved"); } // ---- What a host takes from its group ---- // // The tests below dial. That is the point of them: a resolved value that never reaches // SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting // to the wrong port, or being asked for a password it does not need, with nothing on screen admitting // it. AddHostAsync gives its host a username, so each of these clears what it is about first. [Fact] public async Task AHostThatStatesNoPort_DialsItsGroups() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", port: 2222); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH // factory is ever reached, and this would pass no matter what port was resolved. await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(2222); } [Fact] public async Task AHostThatPinsItsOwnPort_KeepsItUnderAGroupThatSaysOtherwise() { // The other direction, and the one that decides whether inheritance is safe to turn on: a host that // was explicit must not start dialling somewhere else because somebody edited a group. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", port: 2222); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorPort = 22; await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = Host(vault, "prod-db"); // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH // factory is ever reached, and this would pass no matter what port was resolved. await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22); } [Fact] public async Task AHostThatStatesNoUsername_LogsInAsItsGroups() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", username: "deploy"); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorUsername = string.Empty; await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = Host(vault, "prod-db"); // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH // factory is ever reached, and this would pass no matter what port was resolved. await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy"); } [Fact] public async Task AGroupsDefaultPort_ShowsAsThePlaceholderInTheHostEditor() { // What makes an empty box honest. Without this the form asks the user to leave a field blank and // says nothing about what blank will get them. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorPortPlaceholder.ShouldBe("22", "an ungrouped host falls to the end of the chain"); vault.EditorSelectedGroup = vault.EditorGroupChoices.Single( choice => string.Equals(choice.Label, "production", StringComparison.Ordinal)); vault.EditorPortPlaceholder.ShouldBe( "2222", "the placeholder follows the group picker, or it describes the wrong group"); vault.EditorUsernamePlaceholder.ShouldBe("deploy"); } [Fact] public async Task AHostUnderAGroupThatBindsAKey_DoesNotAskForAPassword() { await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", key: "deploy"); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); vault.SelectedHostAsksForAPassword.ShouldBeFalse(); vault.SelectedHostAuthenticationNote.ShouldContain("production"); Host(vault, "prod-db").Authentication.ShouldBe("key"); } [Fact] public async Task AHostPinnedToATypedPassword_KeepsAskingUnderAGroupThatBindsAKey() { // The failure worth ruling out above every other one here. A host deliberately set back to a typed // password must not start authenticating with the fleet's key because somebody set a group default. await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", key: "deploy"); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedAuthentication.ShouldBe( AuthenticationChoice.Inherited, "a host that binds nothing under a group is inheriting, not typing"); vault.EditorSelectedAuthentication = AuthenticationChoice.Typed; await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = Host(vault, "prod-db"); vault.SelectedHostAsksForAPassword.ShouldBeTrue(); Host(vault, "prod-db").Host.AsksForPassword.ShouldBe(true); } [Fact] public async Task AKeyBoundOnlyByAGroup_WarnsAboutTheHostsBeneathItBeforeItIsDeleted() { // Counting each host's own ids would warn about nobody here, and then refuse every host beneath the // group at connect time. The warning is the only thing standing between the two. await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", key: "deploy"); await FileAsync(vault, "prod-db", "production"); vault.SelectedKey = vault.Keys.Single(); vault.DeleteKeyCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().Usage.ShouldContain("prod-db"); } [Fact] public async Task AGroupsParentPicker_LeavesOutItselfAndEverythingBeneathIt() { // A cycle cannot be made here. It can still arrive from two offline re-parents, which is why the // walk carries a visited set — this only keeps a user from doing it to themselves. await UnlockedAsync(); var vault = shell.Vault!; await AddGroupAsync(vault, "estate"); await AddGroupAsync(vault, "production"); await SetGroupParentAsync(vault, "production", "estate"); vault.EditGroupCommand.Execute(vault.Groups.Single( row => string.Equals(row.Label, "estate", StringComparison.Ordinal))); vault.GroupEditorParentChoices .Select(choice => choice.Label) .ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it"); } // ---- What the phone's + drives ---- // // The phone's own pixels are not measurable here and cannot be: the layout suite is net10.0 and // DodoSSH.Client.Android is net10.0-android, so its views are unreachable by construction, and Avalonia's // application is a process global so a second head cannot share this process either. What IS shared is // every property and command the sheet and its two editors bind to, which is all of the behaviour — the // markup only decides where it is drawn. So the flow is tested here and the rectangles go to // docs/manual-checks.md, which is where this project already sends what it cannot assert. [Fact] public async Task TheAddSheet_OffersTwoThingsAndOpensNeitherUntilOneIsChosen() { await UnlockedAsync(); var vault = shell.Vault!; vault.OpenAddSheetCommand.Execute(null); vault.IsAddSheetOpen.ShouldBeTrue(); vault.AnEditorIsOpen.ShouldBeTrue("the + hides while anything is over the list"); vault.IsEditing.ShouldBeFalse(); vault.IsEditingGroup.ShouldBeFalse(); vault.NewHostCommand.Execute(null); vault.IsAddSheetOpen.ShouldBeFalse("choosing lowers the sheet rather than stacking on it"); vault.IsEditing.ShouldBeTrue(); } [Fact] public async Task TheAddSheet_ClosesWithoutOpeningAnything() { await UnlockedAsync(); var vault = shell.Vault!; vault.OpenAddSheetCommand.Execute(null); vault.CloseAddSheetCommand.Execute(null); vault.AnEditorIsOpen.ShouldBeFalse(); vault.IsEditing.ShouldBeFalse(); vault.IsEditingGroup.ShouldBeFalse(); } [Fact] public async Task ANewGroupFromTheSheet_StartsEmptyRatherThanOnTheLastOneEdited() { // The failure this rules out is quiet: a group editor left holding the previous group's default key // would lend it to the next group somebody created without anybody choosing it. await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy", key: "deploy"); vault.OpenAddSheetCommand.Execute(null); vault.NewGroupCommand.Execute(null); vault.IsEditingGroup.ShouldBeTrue(); vault.IsAddSheetOpen.ShouldBeFalse(); vault.EditingGroupId.ShouldBeNull("this is an add, not a rename"); vault.GroupEditorLabel.ShouldBeEmpty(); vault.GroupEditorDefaultPort.ShouldBeNull(); vault.GroupEditorDefaultUsername.ShouldBeEmpty(); vault.GroupEditorSelectedAuthentication.ShouldBe(AuthenticationChoice.NoDefault); vault.GroupEditorSelectedParent.ShouldBe(GroupChoice.None); } [Fact] public async Task AGroupAddedFromTheSheet_CarriesTheDefaultsTypedIntoIt() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewGroupCommand.Execute(null); vault.GroupEditorLabel = "production"; vault.GroupEditorDefaultPort = 2222; vault.GroupEditorDefaultUsername = "deploy"; await vault.SaveGroupCommand.ExecuteAsync(null); vault.IsEditingGroup.ShouldBeFalse("saving lowers the card"); var group = vault.Groups.Single(); group.Group.Label.ShouldBe("production"); group.Group.DefaultPort.ShouldBe(2222); group.Group.DefaultUsername.ShouldBe("deploy"); } [Fact] public async Task TheSheet_RefusesToOpenOverAnEditorRatherThanStackingOnIt() { await UnlockedAsync(); var vault = shell.Vault!; vault.NewHostCommand.Execute(null); vault.OpenAddSheetCommand.Execute(null); vault.IsAddSheetOpen.ShouldBeFalse(); vault.Status.ShouldContain("Finish or cancel", Case.Insensitive); } [Fact] public async Task ANewHostFromTheSheet_LeavesItsPortToWhicheverGroupItIsFiledUnder() { // The phone's add flow end to end, and the reason the port box opens empty: a host created under a // group that says 2222 wants 2222 without anybody typing it, and stays wanting whatever the group // says afterwards. await UnlockedAsync(); var vault = shell.Vault!; await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy"); vault.OpenAddSheetCommand.Execute(null); vault.NewHostCommand.Execute(null); vault.EditorPort.ShouldBeNull("an empty box is what leaves the port to the group"); vault.EditorLabel = "prod-db"; vault.EditorHostname = "db.internal"; vault.EditorSelectedGroup = vault.EditorGroupChoices.Single( choice => string.Equals(choice.Label, "production", StringComparison.Ordinal)); vault.EditorPortPlaceholder.ShouldBe("2222", "the form says what leaving it blank will get you"); vault.EditorUsernamePlaceholder.ShouldBe("deploy"); await vault.SaveHostCommand.ExecuteAsync(null); var host = Host(vault, "prod-db"); host.Host.Port.ShouldBeNull("nothing was typed, so nothing was pinned"); host.Resolved.Port.Value.ShouldBe(2222); host.Resolved.Username.Value.ShouldBe("deploy"); } [Fact] public async Task AGroupsParentPicker_LetsAGroupBeFiledUnderAnother() { await UnlockedAsync(); var vault = shell.Vault!; await AddGroupAsync(vault, "estate"); await AddGroupAsync(vault, "production"); await SetGroupParentAsync(vault, "production", "estate"); vault.Groups .Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal)) .Group.ParentId .ShouldBe(vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)) .EntityId); } [Fact] public async Task AHostUnderANestedGroup_TakesTheNearestAnswerAndKeepsWalkingForTheRest() { // Two levels, and each field resolved on its own: a group that answers one question does not stop // the walk for the others. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "estate"); await AddGroupAsync(vault, "production"); await SetGroupDefaultsAsync(vault, "estate", username: "root"); await SetGroupDefaultsAsync(vault, "production", port: 2222); await SetGroupParentAsync(vault, "production", "estate"); await FileAsync(vault, "prod-db", "production"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorUsername = string.Empty; await vault.SaveHostCommand.ExecuteAsync(null); var host = Host(vault, "prod-db"); host.Resolved.Port.Value.ShouldBe(2222, "the nearer group answers the port"); host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not"); } /// /// ◆ The gesture split, from the side that costs something to get wrong. Choosing a host used to /// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list — /// which meant a tap on a machine's name put five controls in the way of the one thing it obviously /// means. A tap connects now, and this pins that it raises nothing on the way past. /// [Fact] public async Task ATapOnAHost_ConnectsAndRaisesNothing() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.SelectedHost = null; await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]); vault.Status.ShouldContain("Connected", Case.Insensitive); vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to"); vault.IsAskingForConnectPassword.ShouldBeFalse("and nothing was raised over the list to do it"); vault.IsChoosingHosts.ShouldBeFalse("a tap is not a way into selection mode"); } /// /// The one tap that cannot finish, and the whole of what is left of the connect card. A host that /// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the /// password sheet and says so. What it must never do is connect with no password, or leave somebody /// tapping a row that silently does nothing. /// [Fact] public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting() { var vault = await ReadyToConnectAsync(); vault.SelectedHost = null; await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]); vault.IsAskingForConnectPassword.ShouldBeTrue("there is nowhere else to type it"); vault.SelectedHostAsksForAPassword.ShouldBeTrue(); vault.Status.ShouldContain("password"); ssh.Requests.ShouldBeEmpty("nothing was dialled with no password"); // The second tap, with the box filled in, is the one that goes through — otherwise the sheet would // be answering the instruction it just gave with the same instruction again. await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ConnectPassword = "typed-in"; await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]); ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType(); vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded"); } /// /// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker /// makes: a secret left in the box would be somebody else's password sitting in the field the next tap /// reads — and, worse, it would satisfy the emptiness check that decides whether to raise the sheet at /// all, so the next tap would dial with it. /// [Fact] public async Task DismissingThePasswordSheet_EmptiesTheBox() { var vault = await ReadyToConnectAsync(); await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]); vault.ConnectPassword = "half-typed"; vault.RemembersConnectPassword = true; vault.CancelConnectPasswordCommand.Execute(null); vault.IsAskingForConnectPassword.ShouldBeFalse(); vault.ConnectPassword.ShouldBeEmpty(); vault.RemembersConnectPassword.ShouldBeFalse("and the tick beside it is not carried either"); } // ---- ◆ Choosing hosts, and the seven things the action bar does to them ---- // // The connect card is gone and a long press chooses instead. What these pin is the shape of that: the // set survives the things that used to empty it, the entries that are about one machine are offered only // for one, and every run over the set says what it left alone. /// /// The two gestures, from the side the phone drives them. A long press adds rather than toggling — a /// second one on a machine somebody is holding down on must not take the tick off — and a tap toggles /// once the mode is up. Emptying the set leaves the mode, which is the other way out of it. /// [Fact] public async Task ALongPressChoosesAHost_AndTapsTickAndUntickFromThereOn() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "staging"); vault.IsChoosingHosts.ShouldBeFalse(); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.IsChoosingHosts.ShouldBeTrue(); vault.ChosenHostCount.ShouldBe(1); vault.HasOneChosenHost.ShouldBeTrue(); Host(vault, "prod-db").IsChosen.ShouldBeTrue("the tick is drawn on the row"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ChosenHostCount.ShouldBe(1, "a second long press on the same row is not an untick"); vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging")); vault.ChosenHostCount.ShouldBe(2); vault.HasOneChosenHost.ShouldBeFalse("neither the pencil nor CONNECT is about two machines"); vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-db")); vault.IsChoosingHosts.ShouldBeFalse("unticking the last one leaves selection mode"); Host(vault, "prod-db").IsChosen.ShouldBeFalse(); } /// /// The set is held as entity ids rather than as rows, and this is why: every row object in the list is /// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under /// somebody choosing what to do with eleven machines. /// [Fact] public async Task TheChosenHosts_SurviveTheListBeingRebuilt() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "staging"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); await vault.SyncCommand.ExecuteAsync(null); vault.ChosenHostCount.ShouldBe(1, vault.Status); Host(vault, "prod-db").IsChosen.ShouldBeTrue("written back onto the row the reload made"); Host(vault, "staging").IsChosen.ShouldBeFalse(); } /// /// The cross at the left of the bar, and everything it has to take with it: a picker asking which vault /// to move nothing to is not a state worth having. /// [Fact] public async Task ClearingTheChoice_TakesTheMenuAndItsPanelsWithIt() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.OpenHostActionSheetCommand.Execute(null); vault.DeleteChosenHostsCommand.Execute(null); vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu"); vault.IsConfirmingChosenHostDeletion.ShouldBeTrue(); vault.AChosenHostPanelIsOpen.ShouldBeTrue(); vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question"); vault.ClearHostChoiceCommand.Execute(null); vault.IsChoosingHosts.ShouldBeFalse(); vault.IsConfirmingChosenHostDeletion.ShouldBeFalse(); vault.ShowsAddButton.ShouldBeTrue(); } /// /// The pencil at the right of the bar. It edits the one ticked host and leaves selection mode, because /// the editor is a page over the list and a bar counting hosts above a form about one of them would be /// two answers to what the screen is about. /// [Fact] public async Task ThePencil_OpensTheEditorOnTheOneChosenHostAndLeavesSelectionMode() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "staging"); vault.ChooseHostCommand.Execute(Host(vault, "staging")); vault.EditChosenHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue(); vault.EditorLabel.ShouldBe("staging"); vault.IsChoosingHosts.ShouldBeFalse(); vault.CancelEditCommand.Execute(null); // Two ticked, and the pencil has nothing to be about — the bar collapses it rather than refusing it, // but the command has to agree or a stale binding would open the editor on a guess. vault.ChooseHostCommand.Execute(Host(vault, "staging")); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.EditChosenHostCommand.Execute(null); vault.IsEditing.ShouldBeFalse("there is no sensible reading of editing two machines"); } /// /// Filing is the reason the set is worth having: thirty imported machines under one heading used to be /// thirty rounds of open, pick, save. It is the same write dragging a card onto a group makes on the /// desktop, run over the whole selection. /// [Fact] public async Task ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await AddHostAsync(vault, "staging"); await AddGroupAsync(vault, "production"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status); vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal)); await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null); var group = vault.Groups.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal)); Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status); Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId); Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked"); vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode"); } /// /// A question about six hosts does not survive the set becoming seven. /// /// /// The question names a count and the run that answers it reads the set again, and the panel is drawn /// above the list rather than over it — deliberately, so the ticked rows stay in view — which leaves /// every one of them still tickable while it is up. One more tick between the question and the answer /// used to delete a machine nobody had been asked about. The desktop is where this became easy: a /// Ctrl-click or a band is a second's work. See VaultViewModel.deletionAskedAbout. /// [Fact] public async Task TickingAnotherHost_DropsTheDeletionQuestionAlreadyOnScreen() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await AddHostAsync(vault, "staging"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.DeleteChosenHostsCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 2 hosts?"); vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging")); vault.IsConfirmingChosenHostDeletion .ShouldBeFalse("the question was about two of them and there are three now"); vault.IsChoosingHosts.ShouldBeTrue("the set is what was just chosen, so it stays"); vault.DeleteChosenHostsCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 3 hosts?"); // And unticking back to the set it was asked about does not bring a stale question back up. vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging")); vault.IsConfirmingChosenHostDeletion.ShouldBeFalse(); } /// /// /// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the /// cards, and FileChosenHostsUnderCommand went with it; ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce /// covers what a set files to now that the group picker is the only route. What survives here is the /// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host /// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same /// refusal at the point it is raised instead — see — /// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the /// way a card once was. /// /// [Fact] public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await AddHostAsync(vault, "staging"); await AddGroupAsync(vault, "production"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.NewHostCommand.Execute(null); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor"); vault.CancelEditCommand.Execute(null); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now"); var group = vault.Groups.Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal)); vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal)); await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null); Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status); Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId); Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked"); } /// /// The other refusal at the same door, and the one that needs two keychains to raise: a group is an /// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault /// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set — /// see — rather than once per host mid-write, which is /// why no panel opens at all and the status line's sentence has to carry the whole explanation. /// [Fact] public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens() { await UnlockedAsync(); var vaults = shell.Vaults; await vaults.LoadAsync(Token); vaults.NewVaultCommand.Execute(null); vaults.NewVaultName = "Platform secrets"; await vaults.CreateVaultCommand.ExecuteAsync(null); var vault = shell.Vault!; var sharedVaultId = vaults.SelectedVault!.VaultId; await vault.LoadAsync(Token); await AddHostAsync(vault, "prod-db"); vault.NewHostCommand.Execute(null); vault.EditorSelectedVault = vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId); vault.EditorLabel = "prod-web"; vault.EditorHostname = "web.internal"; await vault.SaveHostCommand.ExecuteAsync(null); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain"); vault.Status.ShouldStartWith("These hosts are in more than one keychain"); vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved"); // Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has // to be talked out of. vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status); } /// /// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into /// another vault: the copy stays in the same keychain, so everything it points at is still there. /// [Fact] public async Task DuplicatingTheChosenHosts_WritesACopyBesideEachOne() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); await vault.DuplicateChosenHostsCommand.ExecuteAsync(null); vault.Hosts.Count.ShouldBe(2, vault.Status); var copy = vault.Hosts.Single(row => row.Label.EndsWith("copy", StringComparison.Ordinal)); copy.Label.ShouldBe("prod-db copy"); copy.Host.Hostname.ShouldBe(Host(vault, "prod-db").Host.Hostname); copy.Host.GroupId.ShouldNotBeNull("a duplicate stays on the shelf it was made from"); } /// /// One question naming a count, rather than one question per host: six copies of "delete prod-db?" is /// not a confirmation anybody reads. What is pinned as well is that the question is answerable — the /// panel it is drawn in is above the list rather than in place of it. /// [Fact] public async Task RemovingTheChosenHosts_AsksOnceAndThenTakesThemAll() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-web"); await AddHostAsync(vault, "staging"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web")); vault.DeleteChosenHostsCommand.Execute(null); vault.IsConfirmingChosenHostDeletion.ShouldBeTrue(); vault.PendingDeletion!.Question.ShouldContain("2 hosts"); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Hosts.ShouldHaveSingleItem(vault.Status).Label.ShouldBe("staging"); vault.IsChoosingHosts.ShouldBeFalse(); } /// /// KEEP leaves everything alone, including the ticks: the question was about the selection and declining /// it is not a reason to throw the selection away. /// [Fact] public async Task KeepingTheChosenHosts_LeavesTheTicksWhereTheyWere() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.DeleteChosenHostsCommand.Execute(null); vault.CancelDeleteCommand.Execute(null); vault.Hosts.ShouldHaveSingleItem(); vault.IsChoosingHosts.ShouldBeTrue(); vault.ChosenHostCount.ShouldBe(1); } /// /// With one writable keychain there is nowhere to send anything, and the honest answer is a sentence /// rather than an empty picker. It is also what somebody in a team whose only other vault is read-only /// sees. The two-vault path is VaultSharingTests' job, which is where a second vault exists. /// [Fact] public async Task MovingTheChosenHostsWithNowhereToPutThem_SaysSoRatherThanOpeningAnEmptyPicker() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.MoveChosenHostsToVaultCommand.Execute(null); vault.IsSendingChosenHostsToAVault.ShouldBeFalse(); vault.Status.ShouldContain("only keychain"); vault.IsChoosingHosts.ShouldBeTrue("and the selection is left alone to be used for something else"); } /// /// The bar's own CONNECT, which is the entry a tap already is — it is in the menu because the bar is /// what a long press leaves you in, and without it connecting to the machine you had just chosen would /// mean leaving selection mode first. /// [Fact] public async Task ConnectingFromTheActionBar_OpensTheHostAndLeavesSelectionMode() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ChooseHostCommand.Execute(vault.Hosts[0]); await vault.ConnectToChosenHostCommand.ExecuteAsync(null); vault.Status.ShouldContain("Connected", Case.Insensitive); vault.IsChoosingHosts.ShouldBeFalse(); } /// /// ◆ "Connect via SFTP", which crosses from the vault to the shell. Which machine is a decrypted /// item and so is the vault's; the screen it leads to and the transfers view model behind it are the /// shell's. This pins the join — the host arrives chosen in the file screen's own list, which is a copy /// rebuilt from the vault's, so handing it the vault's row object would select nothing. /// [Fact] public async Task BrowsingAChosenHost_GoesToTheFilesScreenWithThatHostChosen() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.BrowseChosenHostCommand.Execute(null); shell.IsTransfersShowing.ShouldBeTrue(); shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db"); vault.IsChoosingHosts.ShouldBeFalse(); // A host with no key and no credential wants a typed password, and there is nowhere on a list to // give it one — so the picker opens with the machine already chosen and the box beside it, which is // the same branch a tap on the hosts screen makes. shell.Transfers.IsChoosingRemote.ShouldBeTrue(); shell.Transfers.Status.ShouldContain("password"); } // ---- The v5b session sidebar's QUICK ACCESS ---- /// /// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with /// the connection already open and the tab it opened already selected. /// private async Task ConnectedHostWithAPinAsync(string path = "/var/www/app") { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = path; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); await vault.ConnectToChosenHostCommand.ExecuteAsync(null); return vault; } [Fact] public async Task TheSidebar_ShowsTheConnectedTabsHostsPins() { await ConnectedHostWithAPinAsync(); shell.ShowsQuickAccessSidebar.ShouldBeTrue(); shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]); } [Fact] public async Task TheSidebar_StaysHiddenBeforeAnythingConnects() { var vault = await ReadyToConnectAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); // Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed // to a selected tab, not to the host that happens to be selected on the hosts screen. shell.ShowsQuickAccessSidebar.ShouldBeFalse(); shell.ActiveTabPinnedPaths.ShouldBeEmpty(); } /// /// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar /// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on /// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty /// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did. /// [Fact] public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins() { var vault = await ReadyToConnectAsync(); await AddKeyAsync(vault, "deploy"); await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); await vault.ConnectToChosenHostCommand.ExecuteAsync(null); shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing"); shell.ActiveTabPinnedPaths.ShouldBeEmpty(); } [Fact] public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp() { await ConnectedHostWithAPinAsync(); shell.ShowsQuickAccessSidebar.ShouldBeTrue(); shell.ShowScreenCommand.Execute(ShellScreen.Preferences); shell.ShowsQuickAccessSidebar.ShouldBeFalse( "a page is showing, not the terminal or SFTP the sidebar sits beside"); shell.SelectTabCommand.Execute(shell.Tabs[0]); shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected"); } /// /// The other surface the sidebar draws on since v5b: SFTP, gated on Transfers.IsConnected rather /// than on a selected tab, since a session on that screen is its own connection — see /// MainWindowViewModel.ShowsQuickAccessSidebar. /// [Fact] public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected() { await ConnectedHostWithAPinAsync(); await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app"); shell.IsTransfersShowing.ShouldBeTrue(); shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now"); } /// /// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the /// terminal connection and the SFTP one are both real ISshConnectionFactory/ /// ISftpSessionFactory calls against FakeSshConnectionFactory, so this is proof the two /// really are the second authenticated connection the design docs say they are — SftpRequests gets an /// entry independent of Requests. /// [Fact] public async Task ClickingAPinChip_OpensFilesAtThatPath() { var vault = await ConnectedHostWithAPinAsync(); await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app"); shell.IsTransfersShowing.ShouldBeTrue(); shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db"); shell.Transfers.IsConnected.ShouldBeTrue(); shell.Transfers.RemotePath.ShouldBe("/var/www/app"); ssh.SftpRequests.ShouldHaveSingleItem(); vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane"); } // ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ---- /// /// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses — /// see the deviation recorded on MainWindowViewModel.SelectFilesHostCommand. /// [Fact] public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected() { await ConnectedHostWithAPinAsync(); var tab = shell.Tabs[0]; shell.SelectedTab = null; await shell.SelectFilesHostCommand.ExecuteAsync(tab); shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected"); shell.IsTransfersShowing.ShouldBeTrue(); shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db"); shell.Transfers.IsConnected.ShouldBeTrue(); } /// /// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface /// directions the v5b notes ask for, through VaultViewModel.ConnectCommand rather than through a /// tab that does not exist. /// [Fact] public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost() { await ConnectedHostWithAPinAsync(); await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app"); shell.Transfers.IsConnected.ShouldBeTrue(); var tabsBefore = shell.Tabs.Count; await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null); shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host"); shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db"); shell.IsTerminalShowing.ShouldBeTrue(); } /// /// The sidebar's SNIPS row, wired through SnippetsViewModel.InsertCommand rather than a second /// insert path — see the deviation recorded on MainWindowViewModel.InsertSnippetCommand. Proven /// through a real connected tab and a real renderer, the same fixture InsertingASnippet_... above /// uses for the standalone screen, because what is worth proving here is that the shell's command reaches /// that same mechanism rather than reimplementing it. /// [Fact] public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab() { await ConnectedHostWithAPinAsync(); var snippets = shell.SnippetsScreen.ShouldNotBeNull(); await AddSnippetAsync(snippets, "uptime", "uptime", runs: false); var row = snippets.Visible.ShouldHaveSingleItem(); await shell.InsertSnippetCommand.ExecuteAsync(row); snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads"); } [Fact] public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen() { await UnlockedAsync(); shell.AddSnippetFromSidebarCommand.Execute(null); shell.IsSnippetsShowing.ShouldBeTrue(); shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue(); } /// /// The closest honest affordance the v5b notes ask for: this application cannot open a host editor /// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as /// the hosts screen's own EDIT does. /// [Fact] public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor() { await ConnectedHostWithAPinAsync(); var vault = shell.Vault!; shell.PinFolderFromSidebarCommand.Execute(null); shell.IsHostsShowing.ShouldBeTrue(); vault.IsEditing.ShouldBeTrue(); vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db"); } /// /// The status bar's facts, read off the selected terminal tab. /// is real, not fabricated: StartedAt is set from the shell's own clock at the moment the session /// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the /// fake's own, and the identity text is the bound key's label — ConnectedHostWithAPinAsync binds /// "deploy" — composed with the real (unshortened) algorithm string. /// [Fact] public async Task SessionFacts_ReflectTheSelectedTerminalTab() { await ConnectedHostWithAPinAsync(); shell.IsSessionConnected.ShouldBeTrue(); shell.SessionAddress.ShouldBe(shell.Tabs[0].Address); shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session "); shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com"); shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519"); shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy"); } /// /// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than /// a blank or a placeholder standing in for them. /// [Fact] public async Task SessionFacts_AreAbsentWithNoSessionOpen() { await UnlockedAsync(); shell.IsSessionConnected.ShouldBeFalse(); shell.SessionAddress.ShouldBeNull(); shell.SessionElapsedText.ShouldBeNull(); shell.SessionCipher.ShouldBeNull(); shell.SessionHostKeyAlgorithm.ShouldBeNull(); shell.SessionIdentityText.ShouldBeNull(); } /// /// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the /// host-key algorithm alone — no " · " and nothing after it, which is what /// 's own remark promises rather than a placeholder /// standing in for the item that was never there. /// [Fact] public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); vault.ManualTarget = "deploy@build.internal:2222"; vault.ManualPassword = "hunter2"; await vault.ConnectManuallyCommand.ExecuteAsync(null); shell.IsSessionConnected.ShouldBeTrue(); shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519"); shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item"); } /// /// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own /// login through the same resolution ladder, so the status bar names that session's cipher, host key and /// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the /// scrollback still on screen (see TerminalTabViewModel.Cipher's remark). /// [Fact] public async Task SessionFacts_FollowTheSftpSurfacesOwnSession() { await ConnectedHostWithAPinAsync(); await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app"); shell.IsTransfersShowing.ShouldBeTrue(); shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com"); shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy"); await shell.Transfers.DisconnectCommand.ExecuteAsync(null); shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe"); shell.SessionIdentityText.ShouldBeNull(); } [Fact] public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne() { // Both heads' only route into a group editor since v5: neither draws a group card to select one // from any more, so the command has to work off the heading it was pressed on. Two groups exist here // so a bug reading the wrong one would show up as the wrong label rather than passing by accident. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "estate"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var heading = vault.SidebarRows.OfType().Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal)); vault.EditGroupFromHeadingCommand.Execute(heading); vault.IsEditingGroup.ShouldBeTrue(); vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group"); } /// /// The heading hands its group straight to the editor rather than selecting a card first, and this is /// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one /// mark, and neither head has drawn a group card to select since v5 — see EditGroup's own remarks. /// [Fact] public async Task AGroupsHeading_LeavesTheChosenMachineChosen() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var host = vault.Hosts.Single(); vault.SelectedHost = host; var heading = vault.SidebarRows.OfType().Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal)); vault.EditGroupFromHeadingCommand.Execute(heading); vault.IsEditingGroup.ShouldBeTrue("the editor still opens on the group the heading names"); vault.GroupEditorLabel.ShouldBe("production"); vault.SelectedHost.ShouldBeSameAs(host, "and the list is still on the machine it was on"); } [Fact] public async Task TheUngroupedHeading_OpensNothing() { // It has no group behind it. The button is hidden there, so this is the guard for the path the // markup does not control. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); var ungrouped = vault.SidebarRows.OfType() .FirstOrDefault(row => row.GroupId is null); ungrouped.ShouldNotBeNull("a vault with a group and an unfiled host draws an ungrouped heading"); vault.EditGroupFromHeadingCommand.Execute(ungrouped); vault.IsEditingGroup.ShouldBeFalse(); } /// /// /// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at /// all. DeleteGroup and MoveGroup aim at GroupTarget, which is the selected card or /// the open group — and the phone has neither, because its list draws headings and a heading is not a /// thing that list can select. Called bare on that head they would return having done nothing, which is /// a DELETE that appears to have been pressed and has not. /// /// /// The tick is asserted off as well as present. Off is "the machines stay and turn up under /// UNGROUPED", which is recoverable; on is not, and a question that arrived with the destructive answer /// already given would be worse than one that never asked. /// /// [Fact] public async Task AGroupsHeading_AsksAboutThatGroupWithNothingSelected() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var heading = vault.SidebarRows.OfType().Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal)); vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group"); vault.OpenGroupSheetCommand.Execute(heading); vault.GroupSheetLabel.ShouldBe("production", "the menu names what it is about"); vault.AnEditorIsOpen.ShouldBeTrue("so the + stands down, as it does under the add sheet"); vault.DeleteGroupFromHeadingCommand.Execute(heading); vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed"); vault.IsConfirmingGroupDeletion.ShouldBeTrue(); vault.PendingDeletion!.Question.ShouldContain("production"); vault.PendingDeletion.HasChoice.ShouldBeTrue("a host is filed under it, so it has a second question"); vault.DeletionTakesTheHostsToo.ShouldBeFalse("keeping them is the answer that needs no decision"); } /// /// A menu is a thing you are allowed to decide against, which is why this one is dismissible where the /// host key sheet deliberately is not. Nothing may be left armed behind it. /// [Fact] public async Task TheGroupsMenu_WavedAway_LeavesEverythingAsItWas() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); await FileAsync(vault, "prod-db", "production"); var heading = vault.SidebarRows.OfType().Single( row => string.Equals(row.Label, "production", StringComparison.Ordinal)); vault.OpenGroupSheetCommand.Execute(heading); vault.CloseGroupSheetCommand.Execute(null); vault.GroupSheet.ShouldBeNull(); vault.AnEditorIsOpen.ShouldBeFalse(); vault.IsConfirmingDeletion.ShouldBeFalse(); vault.IsMovingGroup.ShouldBeFalse(); vault.IsEditingGroup.ShouldBeFalse(); } [Fact] public async Task TheUngroupedHeading_RaisesNoMenu() { // Nothing behind it for the three entries to act on. The button is left off that row, so this is the // guard for the path the markup does not control. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); var ungrouped = vault.SidebarRows.OfType() .Single(row => row.GroupId is null); vault.OpenGroupSheetCommand.Execute(ungrouped); vault.GroupSheet.ShouldBeNull(); } // ---- Deleting a host, from the phone's bar ---- /// /// ◆ The three panels the action bar's menu can raise, and the rule that at most one is up. They /// are drawn above the list rather than over it — the ticked rows are the information the question exists /// to give — so each one has to disarm the other two on the way up, or two questions about the same six /// machines would be stacked, one of them destructive. /// [Fact] public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddGroupAsync(vault, "production"); vault.ChooseHostCommand.Execute(Host(vault, "prod-db")); vault.DeleteChosenHostsCommand.Execute(null); vault.IsConfirmingChosenHostDeletion.ShouldBeTrue(); vault.RegroupChosenHostsCommand.Execute(null); vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status); vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up"); vault.DeleteChosenHostsCommand.Execute(null); vault.IsConfirmingChosenHostDeletion.ShouldBeTrue(); vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return"); } // ---- Tags ---- // // The type has been storable since the domain landed and unreachable until now. What these pin is the // two halves of making it reachable: a chip is a name resolved through the tag list, and a picker is a // set the host editor edits like any other field. [Fact] public async Task ATagPutOnAHost_ShowsAsAChipAndSurvivesAReload() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]); Host(vault, "prod-db").HasTags.ShouldBeTrue(); Host(vault, "prod-db").Host.TagIds.Count.ShouldBe(1); } [Fact] public async Task RenamingATag_ChangesEveryChipAndRewritesNoHost() { // The whole reason a tag is an item rather than a string on a host. If this ever needed to touch a // host, the type would have earned nothing over repeating the name inside twenty payloads. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "stage-web"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); await TagAsync(vault, "stage-web", "pci"); var before = Host(vault, "prod-db").Host; vault.SelectedTag = vault.Tags.Single(); vault.EditTagCommand.Execute(null); vault.TagEditorLabel = "pci-dss"; await vault.SaveTagCommand.ExecuteAsync(null); Host(vault, "prod-db").TagLabels.ShouldBe(["pci-dss"]); Host(vault, "stage-web").TagLabels.ShouldBe(["pci-dss"]); Host(vault, "prod-db").Host.ShouldBe(before, "renaming a tag must not rewrite a host"); } [Fact] public async Task DeletingATag_LeavesTheHostsAloneAndTheChipsGone() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); var wornBefore = Host(vault, "prod-db").Host.TagIds; vault.SelectedTag = vault.Tags.Single(); vault.DeleteTagCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull().Usage .ShouldContain("1 host", Case.Insensitive, "the count is what makes this decidable"); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.Tags.ShouldBeEmpty(); // The chip is gone and the id is not. Nothing rewrites N payloads inside one delete, so the host // still names a tag that resolves to nothing — and would wear it again if the tag came back. Host(vault, "prod-db").TagLabels.ShouldBeEmpty(); Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore); } /// /// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own /// table does, so EditTagRow/DeleteTagRow select the row and then hand off to the real /// commands above — this proves the hand-off reaches the same place, with the same guard sentences. /// [Fact] public async Task EditTagRow_SelectsTheRowThenOpensTheSameEditorEditTagDoes() { await UnlockedAsync(); var vault = shell.Vault!; await AddTagAsync(vault, "pci"); var row = vault.Tags.Single(); vault.EditTagRowCommand.Execute(row); vault.SelectedTag.ShouldBe(row); vault.IsEditingTag.ShouldBeTrue(); vault.TagEditorLabel.ShouldBe("pci"); } [Fact] public async Task DeleteTagRow_SelectsTheRowThenArmsTheSameConfirmationDeleteTagDoes() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); var row = vault.Tags.Single(); vault.DeleteTagRowCommand.Execute(row); vault.SelectedTag.ShouldBe(row); vault.PendingDeletion.ShouldNotBeNull().Usage .ShouldContain("1 host", Case.Insensitive, "the same guard sentence DeleteTag would have armed"); } [Fact] public async Task ATagCreatedFromTheHostEditor_IsPutOnTheHostBeingEdited() { // Where a tag is usually wanted: while tagging a host and finding it does not exist yet. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.HasTagChoices.ShouldBeFalse("nothing to offer in a keychain with no tags"); vault.EditorNewTag = "eu-west-1"; await vault.AddEditorTagCommand.ExecuteAsync(null); vault.EditorNewTag.ShouldBeEmpty("the box empties so a second one can be typed straight away"); vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue(); await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").TagLabels.ShouldBe(["eu-west-1"]); } [Fact] public async Task ATagTypedTwice_IsUsedRatherThanRepeated() { // Two tags called "staging" are storable and must stay storable — two people creating one offline // is how it happens. Typing the same name into this box is a slip rather than an intention. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewTag = "PCI"; await vault.AddEditorTagCommand.ExecuteAsync(null); vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "matched regardless of case"); vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue(); } [Fact] public async Task ATagTakenOffAHost_LeavesTheTagInTheKeychain() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single()); vault.EditorTagChoices.Single().IsWorn.ShouldBeFalse(); await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").TagLabels.ShouldBeEmpty(); vault.Tags.ShouldHaveSingleItem().HostCount.ShouldBe(0); } [Fact] public async Task CancellingAHostEdit_DropsTheTaggingAndKeepsTheTag() { // The one asymmetry worth pinning. Tagging is a field on the host and goes with a cancel; creating // the tag wrote to the keychain immediately, because a host can only name an id that exists. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewTag = "pci"; await vault.AddEditorTagCommand.ExecuteAsync(null); vault.CancelEditCommand.Execute(null); vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "the tag was never part of the host"); Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was"); } // ---- Pinned paths (QUICK ACCESS) ---- [Fact] public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorPinnedPaths.ShouldBeEmpty(); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away"); vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]); await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]); } [Fact] public async Task PinsAddedInOrder_KeepThatOrderOnTheHost() { // Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has // to preserve it as faithfully as the domain type it is about to become. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" }) { vault.EditorNewPin = path; vault.AddEditorPinCommand.Execute(null); } await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").Host.PinnedPaths .ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]); } [Fact] public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); vault.EditorNewPin = "/etc/nginx"; vault.AddEditorPinCommand.Execute(null); vault.RemoveEditorPinCommand.Execute("/var/www/app"); vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]); await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]); } [Fact] public async Task CancellingAHostEdit_DropsThePinningEntirely() { // Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather // than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe // Tag holds a tag's own name to. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); vault.CancelEditCommand.Execute(null); Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty(); } [Fact] public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]); } [Fact] public async Task ANewHostsEditor_OpensWithNoPinsStaged() { // NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same // reason every other editor field is reset there. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); vault.NewHostCommand.Execute(null); vault.EditorPinnedPaths.ShouldBeEmpty(); } [Fact] public async Task ABlankPin_IsRefusedAtTheAddBox() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = " "; vault.AddEditorPinCommand.Execute(null); vault.EditorPinnedPaths.ShouldBeEmpty(); vault.Status.ShouldContain("blank"); } [Fact] public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); vault.EditorPinnedPaths.ShouldHaveSingleItem(); vault.Status.ShouldContain("already pinned"); } [Fact] public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave() { // The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal // that waits for SAVE throws away every other field typed on the form since, where one at the box // that caused it costs nothing else. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1); vault.AddEditorPinCommand.Execute(null); vault.EditorPinnedPaths.ShouldBeEmpty(); vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture)); } [Fact] public async Task AThirtyThirdPin_IsRefusedAtTheAddBox() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); for (var i = 0; i < HostSecret.MaxPinnedPaths; i++) { vault.EditorNewPin = $"/pin/{i}"; vault.AddEditorPinCommand.Execute(null); } vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths); vault.EditorNewPin = "/one/too/many"; vault.AddEditorPinCommand.Execute(null); vault.EditorPinnedPaths.Count.ShouldBe( HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it"); vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture)); } [Fact] public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore() { // BuildHost rebuilds the whole record from the editor's state, so a tag set that was not carried // through would be stripped by an edit about something else entirely. await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); vault.SelectedHost = Host(vault, "prod-db"); vault.EditSelectedHostCommand.Execute(null); vault.EditorPort = 2222; await vault.SaveHostCommand.ExecuteAsync(null); Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]); } [Fact] public async Task TheKeychainTable_ShowsTagsAndCountsThemUnderAll() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); await AddTagAsync(vault, "pci"); await TagAsync(vault, "prod-db", "pci"); vault.Section = VaultSection.Tags; var row = vault.VaultItems.ShouldHaveSingleItem(); row.Name.ShouldBe("pci"); row.Type.ShouldBe("TAG"); row.Detail.ShouldBe("1 host"); vault.Section = VaultSection.All; vault.TotalItemCount.ShouldBe( vault.VaultItems.Count, "the ALL chip counts what ALL shows"); } [Fact] public async Task OpeningTheTagEditor_TakesAnArmedDeletionAway() { // Every other editor on this screen disarms a pending question when it opens, because the vault // screen's Add buttons stay live beside the detail pane. Without it the tag's boxes would render // directly under a confirmation belonging to an item the user is no longer looking at, and its // DELETE would still be live. await UnlockedAsync(); var vault = shell.Vault!; await AddKeyAsync(vault, "deploy"); vault.Section = VaultSection.Keys; vault.SelectedVaultItem = vault.VaultItems.Single(); vault.DeleteSelectedItemCommand.Execute(null); vault.PendingDeletion.ShouldNotBeNull(); vault.NewTagCommand.Execute(null); vault.IsEditingTag.ShouldBeTrue(); vault.PendingDeletion.ShouldBeNull("the question went with the editor opening"); } private static async Task AddTagAsync(VaultViewModel vault, string label) { vault.NewTagCommand.Execute(null); vault.TagEditorLabel = label; await vault.SaveTagCommand.ExecuteAsync(null); } /// Puts a tag on a host the way a user can: through the host's own editor. private static async Task TagAsync(VaultViewModel vault, string host, string tag) { vault.SelectedHost = Host(vault, host); vault.EditSelectedHostCommand.Execute(null); vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single( choice => string.Equals(choice.Label, tag, StringComparison.Ordinal))); await vault.SaveHostCommand.ExecuteAsync(null); } private static async Task SetGroupDefaultsAsync( VaultViewModel vault, string group, int? port = null, string? username = null, string? key = null) { vault.EditGroupCommand.Execute(vault.Groups.Single( row => string.Equals(row.Label, group, StringComparison.Ordinal))); vault.GroupEditorDefaultPort = port; vault.GroupEditorDefaultUsername = username ?? string.Empty; if (key is not null) { vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single( choice => string.Equals(choice.Label, key, StringComparison.Ordinal)); } await vault.SaveGroupCommand.ExecuteAsync(null); } private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent) { vault.EditGroupCommand.Execute(vault.Groups.Single( row => string.Equals(row.Label, group, StringComparison.Ordinal))); vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single( choice => string.Equals(choice.Label, parent, StringComparison.Ordinal)); await vault.SaveGroupCommand.ExecuteAsync(null); } // ---- Snippets ---- /// /// The default that the whole feature's safety rests on. A snippet somebody writes without thinking /// about the flag has to be one that gets typed and waits, because the alternative is a command that /// runs the first time it is clicked. /// [Fact] public async Task ANewSnippet_DoesNotRunOnItsOwn() { await UnlockedAsync(); var vault = shell.Vault!; var snippets = shell.SnippetsScreen.ShouldNotBeNull(); snippets.NewCommand.Execute(null); snippets.EditorRunsOnInsert.ShouldBeFalse("the box starts off"); snippets.EditorLabel = "restart the api"; snippets.EditorCommand = "sudo systemctl restart dodossh-api"; await snippets.SaveCommand.ExecuteAsync(null); vault.Snippets.ShouldHaveSingleItem().RunsOnInsert.ShouldBeFalse(); } /// /// A here-document's terminator has to arrive on a line of its own with nothing after it. Trim the /// trailing newline and the shell waits for one that never comes, which reads to the user as the snippet /// having hung the terminal — so the command is stored exactly as typed, in the same way key armour is. /// [Fact] public async Task ASnippetsText_IsStoredExactlyAsTyped() { await UnlockedAsync(); var vault = shell.Vault!; var snippets = shell.SnippetsScreen.ShouldNotBeNull(); const string Command = "cat <<'EOF' > /etc/motd\n welcome \nEOF\n"; snippets.NewCommand.Execute(null); snippets.EditorLabel = " set the motd "; snippets.EditorCommand = Command; await snippets.SaveCommand.ExecuteAsync(null); var stored = vault.Snippets.ShouldHaveSingleItem(); stored.Snippet.Command.ShouldBe(Command); stored.Label.ShouldBe("set the motd", "the name is trimmed, and only the name"); } [Fact] public async Task InsertingASnippet_SendsItsTextToTheSelectedTabWithoutRunningIt() { await UnlockedAsync(); var sent = new List<(uint SessionId, string Text, bool Execute)>(); var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent); await AddSnippetAsync(snippets, "uptime", "uptime", runs: false); snippets.Selected = snippets.Visible.Single(); snippets.CanInsert.ShouldBeTrue(); await snippets.InsertCommand.ExecuteAsync(null); var delivered = sent.ShouldHaveSingleItem(); delivered.SessionId.ShouldBe(7u); delivered.Text.ShouldBe("uptime"); delivered.Execute.ShouldBeFalse("INSERT types the command and stops"); } /// /// RUN is offered only for a snippet whose own flag says it runs, so that "this one runs" is a decision /// taken once while writing it. Pressing the command for a snippet without the flag has to do nothing — /// not throw, and above all not send. /// [Fact] public async Task RunningASnippet_IsRefusedUnlessTheSnippetSaysItRuns() { await UnlockedAsync(); var sent = new List<(uint SessionId, string Text, bool Execute)>(); var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent); await AddSnippetAsync(snippets, "safe", "ls -la", runs: false); await AddSnippetAsync(snippets, "armed", "sudo reboot", runs: true); snippets.Selected = snippets.Visible.Single(row => !row.RunsOnInsert); snippets.SelectionRuns.ShouldBeFalse(); await snippets.RunCommand.ExecuteAsync(null); sent.ShouldBeEmpty("this snippet is not one that runs"); snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert); snippets.SelectionRuns.ShouldBeTrue(); await snippets.RunCommand.ExecuteAsync(null); sent.ShouldHaveSingleItem().Execute.ShouldBeTrue(); } [Fact] public async Task InsertingWithNoTerminalOpen_SaysSoAndSendsNothing() { await UnlockedAsync(); var sent = new List<(uint SessionId, string Text, bool Execute)>(); var snippets = SnippetsOver(shell.Vault!, InsertTarget.None, sent); await AddSnippetAsync(snippets, "uptime", "uptime", runs: false); snippets.Selected = snippets.Visible.Single(); snippets.CanInsert.ShouldBeFalse(); snippets.InsertLabel.ShouldBe("NO TERMINAL OPEN"); await snippets.InsertCommand.ExecuteAsync(null); sent.ShouldBeEmpty(); snippets.Status.ShouldContain("Open a terminal first", Case.Sensitive); } /// /// The transport drops frames for a pane nothing is listening to, so a send at a tab whose remote hung /// up succeeds exactly as loudly as one at a live tab. That is why the insert reports back — and why the /// screen has to say so rather than leaving somebody to wonder whether the command landed. /// [Fact] public async Task InsertingIntoATabThatIsNoLongerConnected_SaysSo() { await UnlockedAsync(); var snippets = new SnippetsViewModel( shell.Vault!, () => new InsertTarget(7, "prod-db"), static (_, _, _, _) => Task.FromResult(false)); await AddSnippetAsync(snippets, "uptime", "uptime", runs: false); snippets.Selected = snippets.Visible.Single(); await snippets.InsertCommand.ExecuteAsync(null); snippets.Status.ShouldContain("no longer connected", Case.Sensitive); } /// /// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see /// SnippetsViewModel.RequestDelete's own remarks for why the two are separate commands. Pins the /// two-step shape every other keychain item's deletion already has: arming changes nothing, and only /// confirming actually removes it. /// [Fact] public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed() { await UnlockedAsync(); var snippets = shell.SnippetsScreen.ShouldNotBeNull(); await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false); snippets.Selected = snippets.Visible.Single(); snippets.RequestDeleteCommand.Execute(null); snippets.IsConfirmingDelete.ShouldBeTrue(); snippets.DeleteQuestion.ShouldContain("restart the api"); snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place"); snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself"); snippets.CancelDeleteCommand.Execute(null); snippets.IsConfirmingDelete.ShouldBeFalse(); snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was"); snippets.RequestDeleteCommand.Execute(null); await snippets.ConfirmDeleteCommand.ExecuteAsync(null); snippets.IsConfirmingDelete.ShouldBeFalse(); snippets.Visible.ShouldBeEmpty(); } private static SnippetsViewModel SnippetsOver( VaultViewModel vault, InsertTarget target, List<(uint SessionId, string Text, bool Execute)> sent) => new( vault, () => target, (sessionId, text, execute, _) => { sent.Add((sessionId, text, execute)); return Task.FromResult(true); }); private static async Task AddSnippetAsync( SnippetsViewModel snippets, string label, string command, bool runs) { snippets.NewCommand.Execute(null); snippets.EditorLabel = label; snippets.EditorCommand = command; snippets.EditorRunsOnInsert = runs; await snippets.SaveCommand.ExecuteAsync(null); } private static string[] Headings(VaultViewModel vault) => [.. vault.SidebarRows.OfType() .Where(header => header.GroupId is not null) .Select(header => header.Label)]; private static async Task AddGroupAsync(VaultViewModel vault, string label) { vault.GroupEditorLabel = label; await vault.SaveGroupCommand.ExecuteAsync(null); } /// Files a host into a group the way a user can: through the host's own editor. private static async Task FileAsync(VaultViewModel vault, string host, string group) { vault.SelectedHost = vault.Hosts.Single( row => string.Equals(row.Label, host, StringComparison.Ordinal)); vault.EditSelectedHostCommand.Execute(null); vault.EditorSelectedGroup = vault.EditorGroupChoices.Single( choice => string.Equals(choice.Label, group, StringComparison.Ordinal)); await vault.SaveHostCommand.ExecuteAsync(null); } // ---- Logs ---- /// /// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own /// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins /// that LogSection genuinely has only the two values the segments name, and that the two flags a /// segment's own active state reads are mutually exclusive. /// [Fact] public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections() { await UnlockedAsync(); var logs = shell.LogsScreen.ShouldNotBeNull(); logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections"); logs.ShowsConnections.ShouldBeTrue(); logs.ShowsActivity.ShouldBeFalse(); logs.ShowSectionCommand.Execute(LogSection.Activity); logs.ShowsActivity.ShouldBeTrue(); logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true"); logs.ShowSectionCommand.Execute(LogSection.Connections); logs.ShowsConnections.ShouldBeTrue(); Enum.GetValues().Length.ShouldBe(2, "the design draws two segments and the enum has two"); } /// /// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per- /// section fact this type's header comment already states truthfully, and steps aside for a refresh /// error when refreshing just produced one. /// [Fact] public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError() { await UnlockedAsync(); var logs = shell.LogsScreen.ShouldNotBeNull(); logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes"); logs.ShowSectionCommand.Execute(LogSection.Activity); logs.HeaderStatusLine.ShouldBe("one row per write, per device"); logs.Status = "network unreachable"; logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact"); } // ---- 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); } /// /// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint /// and nothing else. stands in for a provider that refuses. /// private Task ResumeAsync( Uri serverUrl, string refreshToken, CancellationToken cancellationToken) { resumeAttempts++; resumedWith = refreshToken; return resumeFailure is { } failure ? Task.FromException(failure) : Task.FromResult(server); } /// /// A second shell over the same profile directory, as a relaunch of the application is. /// /// /// Its sign-in delegate throws by default, which is the assertion rather than a convenience: a launch /// that reached it would be one that opened a browser at somebody, and every test using this is about /// a launch that must not. /// private MainWindowViewModel Relaunch( IDeviceKeyStore? keys = null, MainWindowViewModel.ResumeHandler? resume = null) => new( paths, caches, workspace, new VaultKnownHostStore(), keys ?? new UnavailableDeviceKeyStore(), (_, _) => throw new InvalidOperationException("The shell opened a browser on launch."), TimeProvider.System, ssh, CheapProfile, resume); 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(); // ---- Unlocking with this machine's device key ---- [Fact] public async Task AnUnlockedVaultOffersToRegisterThisMachine() { await UnlockedAsync(); shell.CanRegisterDevice.ShouldBeTrue(); // Not before: a locked machine with nothing registered has nothing to offer, and the unlock screen // must not show a gesture button for a key it does not have. shell.CanUnlockWithDevice.ShouldBeFalse(); } /// /// The phone's case, tested here because the seam is the shell's. Environment.MachineName answers /// localhost on Android, so a head with no way to say what it is called would put one /// indistinguishable row per phone into the account's device list — which is the list somebody revokes a /// lost handset from, and a row nobody can identify is a revocation nobody dares press. See /// docs/android-port.md §7. /// [Fact] public async Task AHeadThatKnowsWhatThisDeviceIsCalled_RegistersItUnderThatName() { // An enrolled account with a keychain on it, exactly as a phone signing in to an existing account // finds. The registering shell below is a second one over the same profile, which is what every // other "another launch" test in this file does. await UnlockedAsync(); await shell.LockCommand.ExecuteAsync(null); var phone = new MainWindowViewModel( paths, caches, workspace, new VaultKnownHostStore(), deviceKeys, SignInAsync, TimeProvider.System, ssh, CheapProfile, ResumeAsync, deviceName: "Jaap's Pixel"); await using var _ = phone.ConfigureAwait(false); await phone.StartAsync(Token); await phone.SignInCommand.ExecuteAsync(null); phone.Passphrase = Passphrase; await phone.UnlockCommand.ExecuteAsync(null); phone.State.ShouldBe(ShellState.Unlocked, phone.StatusMessage); await phone.RegisterDeviceCommand.ExecuteAsync(null); server.RegisteredDeviceNames.ShouldBe(["Jaap's Pixel"]); // And it is said back, because "this phone can now unlock without your passphrase" is a sentence // about one device out of several. phone.StatusMessage.ShouldContain("Jaap's Pixel"); } [Fact] public async Task AHeadThatSaysNothing_RegistersUnderThisMachinesOwnName() { // The desktop, and the reason the parameter is optional: a head running on an operating system whose // machine name is real passes nothing and gets it. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); server.RegisteredDeviceNames.ShouldBe([Environment.MachineName]); } [Fact] public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase() { // The shell's half of the feature, end to end through the commands a user actually presses. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); shell.CanRegisterDevice.ShouldBeFalse("it is registered now, so the offer is spent"); server.RegisteredDevices.Count.ShouldBe(1); await shell.LockCommand.ExecuteAsync(null); await shell.StartAsync(Token); shell.CanUnlockWithDevice.ShouldBeTrue("the wrap is cached and the keystore still has the key"); await shell.UnlockWithDeviceCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); shell.Passphrase.ShouldBeEmpty("nothing was typed"); } [Fact] public async Task WithdrawingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount() { // The button that makes revocation reachable at all. Until it existed, ForgetDeviceAsync had one // caller and that caller was a test. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); shell.CanForgetDevice.ShouldBeTrue("there is a device key here now"); await shell.ForgetDeviceCommand.ExecuteAsync(null); shell.CanForgetDevice.ShouldBeFalse("the offer is spent"); server.RegisteredDevices.ShouldBeEmpty("the account must not go on listing it"); await shell.LockCommand.ExecuteAsync(null); await shell.StartAsync(Token); shell.CanUnlockWithDevice.ShouldBeFalse("there is nothing left to unlock with"); // And the passphrase still opens it, which is what makes withdrawing safe to offer without a // confirmation prompt. shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); } /// /// The offer and the withdrawal are two flags rather than one and its negation, and this is why: on a /// machine with no keystore both are false, and a single flag would have made "cannot register" mean /// "has something to withdraw". /// [Fact] public async Task OnAMachineWithNoKeystore_ThereIsNothingToWithdrawEither() { deviceKeys.IsAvailable = false; await UnlockedAsync(); shell.CanRegisterDevice.ShouldBeFalse(); shell.CanForgetDevice.ShouldBeFalse(); } [Fact] public async Task WithdrawingTheDeviceOffline_StopsThisMachineAndSaysTheAccountStillListsIt() { // Somebody who has just realised a machine is in the wrong hands may well be on a train. Refusing // until they are online would leave the device unlocking itself for the whole journey. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); await shell.LockCommand.ExecuteAsync(null); // The same keystore, so this machine still holds its device key — only the network is gone. var offline = new MainWindowViewModel( paths, caches, workspace, new VaultKnownHostStore(), deviceKeys, (_, _) => throw new InvalidOperationException("The shell went to the network."), TimeProvider.System, ssh, CheapProfile); await using var _ = offline.ConfigureAwait(false); await offline.StartAsync(Token); offline.IsOnline.ShouldBeFalse(); offline.Passphrase = Passphrase; await offline.UnlockCommand.ExecuteAsync(null); offline.State.ShouldBe(ShellState.Unlocked, offline.StatusMessage); offline.CanForgetDevice.ShouldBeTrue(); await offline.ForgetDeviceCommand.ExecuteAsync(null); offline.StatusMessage.ShouldContain("still lists it"); offline.CanForgetDevice.ShouldBeFalse(); server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend"); // The half that decides whether this machine may let itself in happened anyway. await offline.LockCommand.ExecuteAsync(null); await offline.StartAsync(Token); offline.CanUnlockWithDevice.ShouldBeFalse(); } [Fact] public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears() { deviceKeys.IsAvailable = false; await UnlockedAsync(); shell.CanRegisterDevice.ShouldBeFalse(); shell.CanUnlockWithDevice.ShouldBeFalse(); } [Fact] public async Task ADeclinedGesture_LeavesTheUnlockScreenUsable() { // The fallback that makes the whole thing safe to offer: a cancelled prompt changes the message and // nothing else, and the passphrase still opens the vault. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); await shell.LockCommand.ExecuteAsync(null); await shell.StartAsync(Token); deviceKeys.Decline = true; await shell.UnlockWithDeviceCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Locked); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); } // ---- Staying signed in, and syncing on its own ---- /// /// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been /// set up launched offline and stayed there until somebody found the SIGN IN button on the /// preferences screen — so the sync loop ran once a minute against nothing, and a colleague's change /// arrived when a user happened to go looking for it. /// [Fact] public async Task ARelaunchComesBackOnlineWithoutOpeningABrowser() { await UnlockedAsync(); // The pass that remembers the sign-in. It is the one the loop runs when the vault opens; driven // here rather than raced against. await shell.Vault!.SyncOnOpenAsync(Token); await shell.LockCommand.ExecuteAsync(null); var relaunch = Relaunch(resume: ResumeAsync); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.State.ShouldBe(ShellState.Locked); relaunch.IsOnline.ShouldBeFalse( "the token is sealed under the vault's key, so a locked machine cannot reach the server"); resumeAttempts.ShouldBe(0); relaunch.Passphrase = Passphrase; await relaunch.UnlockCommand.ExecuteAsync(null); await relaunch.Vault!.SyncOnOpenAsync(Token); relaunch.IsOnline.ShouldBeTrue(); resumedWith.ShouldBe("refresh-token-1"); signInAttempts.ShouldBe(1, "the browser opened once, at setup, and must not open again"); } /// /// Providers rotate refresh tokens on use, and a client that persisted only the first one it saw would /// present a retired token on the next launch and be signed out for no visible reason. This is the one /// failure in the mechanism that would look like flakiness rather than a bug. /// [Fact] public async Task ARotatedTokenIsTheOneTheNextLaunchPresents() { await UnlockedAsync(); await shell.Vault!.SyncOnOpenAsync(Token); server.RefreshToken = "refresh-token-2"; await shell.Vault.SyncOnOpenAsync(Token); await shell.LockCommand.ExecuteAsync(null); var relaunch = Relaunch(resume: ResumeAsync); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.Passphrase = Passphrase; await relaunch.UnlockCommand.ExecuteAsync(null); await relaunch.Vault!.SyncOnOpenAsync(Token); resumedWith.ShouldBe("refresh-token-2"); } [Fact] public async Task ARefusedSignIn_IsSaidOnceAndNotRetriedForever() { await UnlockedAsync(); await shell.Vault!.SyncOnOpenAsync(Token); await shell.LockCommand.ExecuteAsync(null); // What a revoked session, or a rotation this machine missed, looks like from the token endpoint. resumeFailure = new OidcException( "The token endpoint returned 400: Invalid refresh token.", "invalid_grant"); var relaunch = Relaunch(resume: ResumeAsync); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.Passphrase = Passphrase; await relaunch.UnlockCommand.ExecuteAsync(null); // The vault opens regardless: nothing about being signed out stops a passphrase working. relaunch.State.ShouldBe(ShellState.Unlocked, relaunch.StatusMessage); await relaunch.Vault!.SyncOnOpenAsync(Token); relaunch.IsOnline.ShouldBeFalse(); relaunch.Vault.Status.ShouldContain("expired", Case.Insensitive); var attempted = resumeAttempts; attempted.ShouldBeGreaterThan(0); // And the token is dropped rather than retried once a minute for the life of the profile. await relaunch.Vault.SyncOnOpenAsync(Token); resumeAttempts.ShouldBe(attempted); } /// /// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the /// loop is started from inside the unlock command, so the busy flag it yields to was raised by the /// unlock itself. It cost a full minute of a machine that was online and out of date, and no test saw /// it because every test called the pass by hand with nothing busy. /// [Fact] public async Task ThePassOnOpen_RunsEvenThoughUnlockingIsStillBusy() { 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"); // Standing in for the unlock command that is still running when the loop starts its first pass. vault.IsBusy = true; await vault.SyncOnOpenAsync(Token); vault.PendingChanges.ShouldBe(0, "the pass on open does not yield to the unlock that started it"); server.LiveRowCount.ShouldBe(1); vault.IsBusy = false; } // ---- Signing out ---- [Fact] public async Task SigningOutIsAQuestionFirst() { await UnlockedAsync(); shell.SignOutCommand.Execute(null); shell.IsConfirmingSignOut.ShouldBeTrue(); shell.IsAskingForThePassphrase.ShouldBeFalse("the two cards swap rather than stack"); shell.State.ShouldBe(ShellState.Unlocked, "arming the question changes nothing else"); shell.Vault.ShouldNotBeNull(); shell.CancelSignOutCommand.Execute(null); shell.IsConfirmingSignOut.ShouldBeFalse(); shell.State.ShouldBe(ShellState.Unlocked); shell.Vault.ShouldNotBeNull("cancelling must not have closed anything"); } [Fact] public async Task SigningOut_DeletesThisMachinesCopyAndLeavesTheVaultOnTheServer() { await UnlockedAsync(); await AddHostAsync(shell.Vault!, "prod-db"); server.LiveRowCount.ShouldBe(1); shell.SignOutCommand.Execute(null); await shell.ConfirmSignOutCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.NeedsServer); shell.Vault.ShouldBeNull("the vault's keys are gone"); shell.IsOnline.ShouldBeFalse("and so is the connection"); shell.AccountName.ShouldBeNull(); shell.IsConfirmingSignOut.ShouldBeFalse(); server.LiveRowCount.ShouldBe(1, "the vault lives on the server and signing out does not touch it"); // A relaunch finds a machine that has never been set up, which is what "reset" has to mean. var relaunch = Relaunch(); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.State.ShouldBe(ShellState.NeedsServer); relaunch.AccountName.ShouldBeNull(); } /// /// The half that makes signing out a reset rather than a wipe: the cache is emptied and immediately /// usable, so setting the machine up again needs no restart. It is also the way back for somebody who /// has forgotten their passphrase, which is why the button is on the unlock screen too. /// [Fact] public async Task AfterSigningOut_TheSameApplicationCanBeSetUpAgain() { await UnlockedAsync(); shell.SignOutCommand.Execute(null); await shell.ConfirmSignOutCommand.ExecuteAsync(null); await shell.SignInCommand.ExecuteAsync(null); // The account is already enrolled — this machine forgot it, the server did not — so the wrap and // the salt are cached again from /me and the old passphrase still opens them. shell.State.ShouldBe(ShellState.Locked, shell.StatusMessage); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); shell.Vault.ShouldNotBeNull(); } [Fact] public async Task SigningOutWithQueuedChanges_SaysHowManyWillBeLost() { await UnlockedAsync(); var vault = shell.Vault!; // A change this machine made and could not send is the one thing signing out destroys that // nothing else has a copy of, so the count is the whole point of the confirmation. server.SyncFailure = new HttpRequestException("The server is having a bad day."); await AddHostAsync(vault, "prod-db"); vault.PendingChanges.ShouldBe(1); shell.SignOutCommand.Execute(null); shell.SignOutWarning.ShouldContain("1 change"); shell.SignOutWarning.ShouldContain("lost"); } [Fact] public async Task SigningOutWhileLocked_AdmitsItCannotCountWhatWouldBeLost() { await UnlockedAsync(); await shell.LockCommand.ExecuteAsync(null); shell.SignOutCommand.Execute(null); // The outbox is sealed under the key the vault holds, so a locked machine genuinely cannot count // it. Saying "nothing will be lost" here would be a claim this state cannot support. shell.SignOutWarning.ShouldContain("cannot be counted"); } [Fact] public async Task SigningOut_WithdrawsThisMachineFromTheAccount() { // The leftover ADR 0007 is about: a device wrap on the account whose private half has just been // deleted is one nobody can account for and nothing can use. await UnlockedAsync(); await shell.RegisterDeviceCommand.ExecuteAsync(null); server.RegisteredDevices.Count.ShouldBe(1); shell.SignOutCommand.Execute(null); await shell.ConfirmSignOutCommand.ExecuteAsync(null); server.RegisteredDevices.ShouldBeEmpty(); deviceKeys.Peek().ShouldBeNull("this machine's own copy of the key goes too"); var relaunch = Relaunch(keys: deviceKeys); await using var _ = relaunch.ConfigureAwait(false); await relaunch.StartAsync(Token); relaunch.CanUnlockWithDevice.ShouldBeFalse(); } /// /// Signing out is the strongest thing this application does to itself, and it deliberately does not do /// the one thing locking refuses to do either. The argument is the same one LockAsync carries: /// a session that authenticated before is still running somebody's job, and a button that destroyed it /// would be a button people stop pressing. /// [Fact] public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo() { await UnlockedAsync(); await workspace.OpenSessionAsync( new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")), TerminalSize.Default, progress: null, Token); shell.SignOutCommand.Execute(null); shell.HasLiveSessions.ShouldBeTrue(); shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running."); await shell.ConfirmSignOutCommand.ExecuteAsync(null); workspace.LiveSessionCount.ShouldBe(1); shell.State.ShouldBe(ShellState.NeedsServer); } // ---- File transfer ---- /// /// The list is followed rather than copied at unlock, which is the whole of this test. A snapshot taken /// when the vault opened meant a host created five seconds later could not be picked here until the /// keychain had been locked and opened again — and nothing on the screen explained why the machine that /// was plainly in the host list was missing from the picker. /// [Fact] public async Task TheTransfersScreen_FollowsTheVaultsHostList() { await UnlockedAsync(); shell.Transfers.Hosts.ShouldBeEmpty("the vault has no hosts yet"); await AddHostAsync(shell.Vault!, "prod-db"); shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]); shell.Transfers.SelectedHost?.Label.ShouldBe("prod-db", "the only host is the one to offer"); await shell.LockCommand.ExecuteAsync(null); shell.Transfers.Hosts.ShouldBeEmpty("those rows carry decrypted secrets"); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]); } /// /// The lock policy, applied to the other thing that can be in flight. LockAsync argues that /// locking must not destroy work — it is what somebody does when they walk away from the machine, which /// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have /// dropped the session and with it whatever was moving. /// [Fact] public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway() { await UnlockedAsync(); var vault = shell.Vault!; await AddHostAsync(vault, "prod-db"); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); await shell.LockCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect"); // What it does take is the host list, and it has to: those rows carry decrypted secrets and the // vault they came from has just been disposed. shell.Transfers.Hosts.ShouldBeEmpty(); shell.Transfers.SelectedHost.ShouldBeNull(); } /// /// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made /// visible: browsing a host's files authenticates again rather than reusing the terminal's connection. /// It is asserted rather than merely written down because the host's audit log shows a second login, /// and somebody will eventually be asked to explain it. /// [Fact] public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals() { var vault = await ReadyToConnectAsync(); await ConnectWithRendererAsync(vault); ssh.Requests.Count.ShouldBe(1); ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session"); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); ssh.SftpRequests.Count.ShouldBe(1); ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either"); // It opens on the account's home directory, which is the only path the layer knows without asking. shell.Transfers.RemotePath.ShouldBe("/home/deploy"); shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]); } /// /// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin /// saved on the host before this screen ever connects to it is read straight off the row's own /// HostSecret.PinnedPaths at the moment MarkHostConnected runs, which is what /// 's own remark promises rather than a live follow /// of the vault. /// [Fact] public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths() { var vault = await ReadyToConnectAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]); shell.Transfers.HasConnectedPins.ShouldBeTrue(); } /// /// The other half of : the /// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would /// show the previous host's. /// [Fact] public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths() { var vault = await ReadyToConnectAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewPin = "/var/www/app"; vault.AddEditorPinCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.HasConnectedPins.ShouldBeTrue(); await shell.Transfers.DisconnectCommand.ExecuteAsync(null); shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty(); shell.Transfers.HasConnectedPins.ShouldBeFalse(); } /// /// The phone's foreground-service question, proven at the view model rather than through Android: a /// connect that opens an SFTP session is exactly the transition SessionKeepAlive needs to hear /// about even when no transfer ever moves — see 's own /// remark for why the queue's own raise, in OnTransferChanged, cannot cover a connect that never /// touches Transfers at all. /// [Fact] public async Task ConnectingATransfersHost_RaisesActivityChangedAndTurnsOnHasLiveFileSession() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; var raised = 0; shell.Transfers.ActivityChanged += (_, _) => raised++; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); shell.Transfers.HasLiveFileSession.ShouldBeTrue(); raised.ShouldBeGreaterThan(0); } /// /// The other half: a disconnect is as much a transition the service must hear about as a connect is, /// because it is the moment the connection promised /// was open stops being true — and the foreground service would otherwise keep the process alive over a /// session that has already closed. /// [Fact] public async Task DisconnectingTheTransfersScreen_RaisesActivityChangedAndTurnsOffHasLiveFileSession() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.HasLiveFileSession.ShouldBeTrue(); var raised = 0; shell.Transfers.ActivityChanged += (_, _) => raised++; await shell.Transfers.DisconnectCommand.ExecuteAsync(null); shell.Transfers.HasLiveFileSession.ShouldBeFalse(); raised.ShouldBeGreaterThan(0); } /// /// A bucket is an IRemoteFileStore with no HostSecret underneath it, so there is no /// PinnedPaths to read at all — see 's own remark. /// The bucket here is created through the same keychain route /// exercises, and /// stands in for the network the way /// already does for SFTP. /// [Fact] public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory()); vault.NewObjectStoreCommand.Execute(null); vault.BucketEditorLabel = "Backups"; vault.BucketEditorBucket = "backups"; vault.BucketEditorAccessKeyId = "AKIAEXAMPLE"; vault.BucketEditorSecretAccessKey = "a-secret-access-key"; vault.BucketEditorRegion = "eu-west-1"; await vault.SaveObjectStoreCommand.ExecuteAsync(null); // Remote is what ConnectAsync branches on, and Attach's RefreshHosts has already auto-selected the // host ReadyToConnectAsync left in the picker — without this line the command below dialled that // host, and every assertion here passed only because that host happens to have no pins either. The // ConnectedTo check is the proof the bucket path was actually taken. shell.Transfers.Remote = RemoteKind.Bucket; shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.ConnectedTo.ShouldBe("s3://backups"); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty(); shell.Transfers.HasConnectedPins.ShouldBeFalse(); } /// /// A bucket is HTTP, per-request, with nothing open that a dying process would lose — see /// 's own remark. IsConnected alone would have /// answered this wrongly, which is exactly why the flag reads ConnectedCipher as well: nothing /// underneath a bucket ever sets it. /// [Fact] public async Task ConnectingABucket_LeavesHasLiveFileSessionOff() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory()); vault.NewObjectStoreCommand.Execute(null); vault.BucketEditorLabel = "Backups"; vault.BucketEditorBucket = "backups"; vault.BucketEditorAccessKeyId = "AKIAEXAMPLE"; vault.BucketEditorSecretAccessKey = "a-secret-access-key"; vault.BucketEditorRegion = "eu-west-1"; await vault.SaveObjectStoreCommand.ExecuteAsync(null); // ReadyToConnectAsync already left a host in the picker, and Attach's own RefreshHosts auto-selects // it — so without this the CONNECT command below would dial that host rather than open the bucket, // and a host with no pins would make ConnectedPinnedPathsEmpty-style assertions pass for the wrong // reason. Remote is what ConnectAsync actually branches on. shell.Transfers.Remote = RemoteKind.Bucket; shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.ConnectedTo.ShouldBe("s3://backups", "proof this opened the bucket rather than the host"); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); shell.Transfers.HasLiveFileSession.ShouldBeFalse(); } /// A bucket that opens and lists as empty, so a bucket connect can be proven with no network. private sealed class FakeObjectStoreFactory : IObjectStoreFactory { public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore(); } /// The minimum a bucket connect touches: home, then a listing. private sealed class FakeBucketStore : IRemoteFileStore { public bool IsConnected => true; public string HomeDirectory => "/"; public Task> ListAsync(string path, CancellationToken cancellationToken) => Task.FromResult>([]); public Task StatAsync(string path, CancellationToken cancellationToken) => Task.FromResult(null); public Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) => throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins."); public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) => throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins."); public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) => throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins."); public Task DeleteAsync(string path, CancellationToken cancellationToken) => throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins."); public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) => throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins."); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } /// /// /// The picker that replaced the desktop's connect bar, and the four things that put it away again. It /// is a flag rather than a screen, so what is worth asserting is that it is never left open over a pane /// it no longer belongs to: connecting closes it, disconnecting closes it, moving between SFTP and S3 /// closes it, and losing the vault closes it. /// /// /// The last two are the ones that would rot quietly. A picker surviving a hop to the other tab offers /// hosts on a screen showing buckets, and one surviving a lock offers a list that has just been emptied /// because its rows carried decrypted secrets. /// /// [Fact] public async Task TheFilePickerIsPutAwayByEverythingThatChangesWhatItWouldBePicking() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.IsChoosingRemote.ShouldBeFalse("the pane opens on its invitation"); // Connecting. shell.Transfers.BeginChoosingRemoteCommand.Execute(null); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; await shell.Transfers.ConnectCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status); shell.Transfers.IsChoosingRemote.ShouldBeFalse("what is open is the listing now"); // Disconnecting, which puts the pane back to the invitation rather than to the form. await shell.Transfers.DisconnectCommand.ExecuteAsync(null); shell.Transfers.IsConnected.ShouldBeFalse(shell.Transfers.Status); shell.Transfers.IsChoosingRemote.ShouldBeFalse(); // Moving to the other destination, which has a different list behind it. shell.Transfers.BeginChoosingRemoteCommand.Execute(null); shell.ShowFilesCommand.Execute(RemoteKind.Bucket); shell.Transfers.ShowsBucketPicker.ShouldBeTrue(); shell.Transfers.IsChoosingRemote.ShouldBeFalse("that picker was offering hosts"); // Cancelling, which also takes the typed password with it. shell.Transfers.BeginChoosingRemoteCommand.Execute(null); shell.Transfers.TypedPassword = "hunter2"; shell.Transfers.CancelChoosingRemoteCommand.Execute(null); shell.Transfers.IsChoosingRemote.ShouldBeFalse(); shell.Transfers.TypedPassword.ShouldBeEmpty("a secret nobody asked to keep"); // And losing the vault, which empties both lists. shell.Transfers.BeginChoosingRemoteCommand.Execute(null); await shell.LockCommand.ExecuteAsync(null); shell.Transfers.Hosts.ShouldBeEmpty(); shell.Transfers.IsChoosingRemote.ShouldBeFalse("a form offering a choice between nothing"); } /// /// /// The S3 screen with nothing in the keychain, which is where every new account starts and which used to /// be a dead end: SELECT BUCKET opened a combo box with nothing in it, and nothing anywhere said that a /// bucket is made on the keychain screen. Somebody standing here had arrived at the place a bucket is /// used and been shown no route to the place one is created — which is indistinguishable from an /// application that cannot add one at all. /// /// /// The assertion that matters is the last pair: pressing the button lands on the keychain with the /// editor already open. Navigating to the screen and leaving the user to find + BUCKET among five /// buttons would be most of the same problem. /// /// [Fact] public async Task TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere() { var vault = await ReadyToConnectAsync(); shell.Transfers.Attach(vault, knownHosts); shell.ShowFilesCommand.Execute(RemoteKind.Bucket); shell.Transfers.ShowsNoBuckets.ShouldBeTrue("nothing has been added to the keychain"); shell.Transfers.ShowsBucketChoice.ShouldBeFalse("there is nothing to choose between"); shell.Transfers.AddBucketCommand.Execute(null); shell.Screen.ShouldBe(ShellScreen.Keychain); vault.IsEditingObjectStore.ShouldBeTrue("the route has to land on the editor, not near it"); vault.BucketEditorLabel = "Backups"; vault.BucketEditorBucket = "backups"; vault.BucketEditorAccessKeyId = "AKIAEXAMPLE"; vault.BucketEditorSecretAccessKey = "a-secret-access-key"; vault.BucketEditorRegion = "eu-west-1"; await vault.SaveObjectStoreCommand.ExecuteAsync(null); shell.ShowFilesCommand.Execute(RemoteKind.Bucket); shell.Transfers.ShowsNoBuckets.ShouldBeFalse(vault.Status); shell.Transfers.ShowsBucketChoice.ShouldBeTrue("the bucket that was just made is the one to open"); } 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); } /// The host row with a given name, which the list orders by label rather than by age. private static HostRowViewModel Host(VaultViewModel vault, string label) => vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal)); /// Deletes the selected host: the question, and then the answer to it. /// /// Both halves, because both are what deleting anything now takes — arming on its own changes nothing, /// which is what DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered holds it to. Tests /// about something else go through these three helpers, so the two-step is spelled out in one place /// rather than in ten. /// private static async Task DeleteSelectedHostAsync(VaultViewModel vault) { vault.DeleteHostCommand.Execute(null); await vault.ConfirmDeleteCommand.ExecuteAsync(null); } /// private static async Task DeleteSelectedKeyAsync(VaultViewModel vault) { vault.DeleteKeyCommand.Execute(null); await vault.ConfirmDeleteCommand.ExecuteAsync(null); } /// private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault) { vault.DeleteCredentialCommand.Execute(null); await vault.ConfirmDeleteCommand.ExecuteAsync(null); } /// Points a host at a key through the editor, the way a user would. private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) => BindAsync(vault, host, AuthenticationKind.SshKey, keyId); /// Points a host at a stored credential through the same picker. private static Task BindCredentialAsync(VaultViewModel vault, HostRowViewModel host, Guid credentialId) => BindAsync(vault, host, AuthenticationKind.Credential, credentialId); /// /// One helper for both because there is one control for both, and a test that reached for the binding a /// different way than the interface does would stop covering the interface. /// private static async Task BindAsync( VaultViewModel vault, HostRowViewModel host, AuthenticationKind kind, Guid entityId) { vault.SelectedHost = host; vault.EditSelectedHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue("the host editor has to be open for the picker to be populated"); vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Kind == kind && choice.EntityId == entityId); await vault.SaveHostCommand.ExecuteAsync(null); } private static async Task AddCredentialAsync( VaultViewModel vault, string label, string password = "s3cret", string username = "") { vault.NewCredentialCommand.Execute(null); vault.CredentialEditorLabel = label; vault.CredentialEditorPassword = password; vault.CredentialEditorUsername = username; await vault.SaveCredentialCommand.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); } /// /// The same, for a connection that is expected to store its password. /// /// /// Without the status assertion, and that is the whole reason it is separate. Remembering writes two /// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own /// count — leaving "Connected" true of what happened and false of what the line says. What the connection /// actually did is asserted on the vault, which is where it is durable. /// private async Task ConnectAndRememberAsync(VaultViewModel vault) { await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded"); } // ---- v5c: settings mode ---- // // The window-level mode that swaps the titlebar, the rail and the page area for settings mode's own — // see MainWindowViewModel.EnterSettings and design-notes/v5c-fidelity-notes.md. What is worth proving at // this level, with no Avalonia involved, is the state machine itself: entering and leaving preserves // wherever the user actually was, switching between settings pages does not forget it, and the two // pages that mirror an existing ShellScreen keep every binding written against that screen before this // mode existed. /// /// The core promise of "Back to application": whatever screen a user was on survives a trip through /// settings mode untouched, however many pages they visit while they are there. /// [Fact] public async Task EnteringAndLeavingSettingsMode_PreservesTheScreenItWasEnteredFrom() { await ReadyToConnectAsync(); shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.EnterSettingsCommand.Execute(SettingsPage.General); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.General); // Switching pages inside settings mode must not overwrite the remembered return screen with a // settings page of its own — see the remark on MainWindowViewModel.settingsReturnScreen. shell.EnterSettingsCommand.Execute(SettingsPage.Security); shell.EnterSettingsCommand.Execute(SettingsPage.Preferences); shell.LeaveSettingsCommand.Execute(null); shell.IsSettingsMode.ShouldBeFalse(); shell.ActiveSettingsPage.ShouldBeNull(); shell.Screen.ShouldBe(ShellScreen.Keychain); } /// /// Settings mode collapses the terminal the same way any other page does — /// and are exclusive by construction — and "Back to application" has /// to bring it back rather than leaving the user on a page they never asked for. /// [Fact] public async Task EnteringSettingsModeFromATerminal_CollapsesItAndLeavingRestoresIt() { var vault = await ReadyToConnectAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); shell.IsTerminalSurface.ShouldBeTrue(); shell.EnterSettingsCommand.Execute(SettingsPage.Security); shell.IsTerminalSurface.ShouldBeFalse("settings mode occupies the same rectangle a page does"); shell.IsSettingsMode.ShouldBeTrue(); shell.LeaveSettingsCommand.Execute(null); shell.IsTerminalSurface.ShouldBeTrue(); shell.IsSettingsMode.ShouldBeFalse(); } /// /// v5c: and are settings pages /// now, so anything that still navigates to either — a test written before this wave, the phone's own /// hub — is redirected into settings mode on the matching page rather than landing on a screen the /// design retired. is kept in step with the two so every /// existing binding written against either screen keeps its answer. /// /// Two s over one private body rather than a : ShellScreen /// and SettingsPage are both internal, and a public theory method may not carry an /// internal type in its signature. /// /// [Fact] public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() => ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences); [Fact] public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() => ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults); /// /// v5c-2: Groups and Tags joined settings mode with no counterpart — managing /// either has never been its own screen before this wave — so there is no redirect to prove, only that /// reaches each directly. /// [Fact] public void EnteringSettingsOnGroups_ShowsTheGroupsPage() { shell.EnterSettingsCommand.Execute(SettingsPage.Groups); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.Groups); shell.IsSettingsGroupsPage.ShouldBeTrue(); } [Fact] public void EnteringSettingsOnTags_ShowsTheTagsPage() { shell.EnterSettingsCommand.Execute(SettingsPage.Tags); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.Tags); shell.IsSettingsTagsPage.ShouldBeTrue(); } private void ShowingAScreenEntersSettingsModeOn(ShellScreen screen, SettingsPage page) { shell.ShowScreenCommand.Execute(screen); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(page); shell.Screen.ShouldBe(screen); shell.IsShowingPages.ShouldBeTrue(); } /// /// A caller that names an ordinary screen while settings mode is up is not asking to go back to /// wherever settings was entered from — it is asking for that screen, which wins over "Back to /// application" restoring anything. /// [Fact] public void NavigatingToAnOrdinaryScreenWhileInSettingsMode_LeavesSettingsModeOutright() { shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.EnterSettingsCommand.Execute(SettingsPage.Security); shell.ShowScreenCommand.Execute(ShellScreen.Hosts); shell.IsSettingsMode.ShouldBeFalse(); shell.Screen.ShouldBe(ShellScreen.Hosts); } /// /// The confirmation card moved from the old bare Preferences screen to the Account settings page — see /// — and this is the one command both the rail's /// popover Logout row and settings mode's own bottom Logout row call, so there is exactly one place the /// card is armed from. /// [Fact] public async Task SignOutFromPopover_EntersSettingsOnAccountAndArmsTheConfirmation() { await ReadyToConnectAsync(); shell.SignOutFromPopoverCommand.Execute(null); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account); shell.IsConfirmingSignOut.ShouldBeTrue(); } // ---- v5c-3: the importer, inside settings mode ---- // // Import.dc.html draws the importer over the Preferences page, with SettingsNav still lit on // Preferences — so ActiveSettingsPage never actually leaves SettingsPage.Preferences; only // MainWindowViewModel.IsImportOpen and IsSettingsPreferencesContentShowing move. See ShowScreen's own // translation of ShellScreen.Import, which is the Preferences page's "OPEN IMPORTER" row and every other // caller that used to land on the old bare screen. [Fact] public void ShowingImport_OpensTheImporterOverThePreferencesPage() { shell.ShowScreenCommand.Execute(ShellScreen.Import); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences, "SettingsNav stays lit on Preferences"); shell.IsSettingsPreferencesPage.ShouldBeTrue(); shell.IsImportOpen.ShouldBeTrue(); shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the importer is drawn over it, not beside it"); } /// The titlebar's own "Back to preferences": closes the importer without leaving settings mode. [Fact] public void CloseImport_ReturnsToPreferencesWithoutLeavingSettingsMode() { shell.ShowScreenCommand.Execute(ShellScreen.Import); shell.CloseImportCommand.Execute(null); shell.IsSettingsMode.ShouldBeTrue(); shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences); shell.IsImportOpen.ShouldBeFalse(); shell.IsSettingsPreferencesContentShowing.ShouldBeTrue(); } /// The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate. [Fact] public async Task TheImporterScreensCancelButton_ClosesItTheSameWayTheTitlebarDoes() { await UnlockedAsync(); shell.ShowScreenCommand.Execute(ShellScreen.Import); shell.IsImportOpen.ShouldBeTrue(); shell.ImportScreen!.CancelCommand.Execute(null); shell.IsSettingsMode.ShouldBeTrue("Cancel backs out to Preferences, not out of Settings altogether"); shell.IsImportOpen.ShouldBeFalse(); } /// /// Naming a settings page — including Preferences again — while the importer is up is a request for that /// page, not for whatever was drawn over it last time. Covers the nav rail's own Preferences row as well /// as every other page. /// [Fact] public void EnteringAnySettingsPageWhileImportIsOpen_ClosesTheImporter() { shell.ShowScreenCommand.Execute(ShellScreen.Import); shell.IsImportOpen.ShouldBeTrue(); shell.EnterSettingsCommand.Execute(SettingsPage.Preferences); shell.IsImportOpen.ShouldBeFalse(); shell.IsSettingsPreferencesContentShowing.ShouldBeTrue(); } [Fact] public void LeavingSettingsModeWhileImportIsOpen_ClosesTheImporterToo() { shell.ShowScreenCommand.Execute(ShellScreen.Keychain); shell.ShowScreenCommand.Execute(ShellScreen.Import); shell.LeaveSettingsCommand.Execute(null); shell.IsSettingsMode.ShouldBeFalse(); shell.IsImportOpen.ShouldBeFalse("a stale flag here would reopen the importer the next time Settings is entered"); } /// /// is new in v5c, for the Account settings page's SIGN-IN row — /// see the property's own remark. MeResponse.Issuer was already being cached into /// StoredUnlockMaterial for no reader before this wave; this is the first assertion that it also /// reaches the shell. /// [Fact] public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow() { await UnlockedAsync(); shell.Issuer.ShouldBe("https://idp.example/realms/dodossh"); } /// 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; } }