Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
+869 -11
View File
@@ -1,10 +1,10 @@
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
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.Terminal;
@@ -494,6 +494,250 @@ public sealed class ShellFlowTests : IAsyncLifetime
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.
/// <remarks>
/// 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.
/// </remarks>
[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.Vault);
shell.IsTerminalShowing.ShouldBeFalse();
shell.IsShowingPages.ShouldBeTrue();
shell.IsVaultShowing.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);
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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.Vault);
LitEntries().ShouldBe(1);
shell.IsVaultShowing.ShouldBeTrue();
int LitEntries() => new[]
{
shell.IsHostsShowing,
shell.IsTransfersShowing,
shell.IsVaultShowing,
shell.IsTeamShowing,
shell.IsPreferencesShowing,
}.Count(lit => lit);
}
/// <remarks>
/// The palette can be opened from any screen, and an unknown host key is answered by a prompt drawn on
/// the hosts screen. Without this the connection would block on a question sitting behind whatever screen
/// the user happened to be on.
/// </remarks>
[Fact]
public async Task ConnectingFromThePalette_LandsOnTheHostsPageBeforeItCanBeRefused()
{
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.IsHostsShowing.ShouldBeTrue("the prompt is drawn on the hosts screen");
shell.IsTerminalShowing.ShouldBeFalse();
}
[Fact]
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
{
@@ -1609,7 +1853,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
vault.Status.ShouldContain("not in this vault");
vault.Status.ShouldContain("not in this keychain");
}
[Fact]
@@ -1844,7 +2088,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your vault");
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain");
}
// ---- Authenticating with a stored credential ----
@@ -1952,7 +2196,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
vault.Status.ShouldContain("credential that is not in this vault");
vault.Status.ShouldContain("credential that is not in this keychain");
}
[Fact]
@@ -2169,18 +2413,235 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.KnownHostPins.ShouldHaveSingleItem();
}
/// <remarks>
/// 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
/// <c>OnVaultChanged</c>, so a vault opened by any path other than the one this test takes would leave
/// the nav rail pointing at a null.
/// </remarks>
[Fact]
public async Task ThePinSectionIsReachableAndTakesItsTurn()
public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes()
{
await UnlockedAsync();
var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it");
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
shell.IsKnownHostsShowing.ShouldBeTrue();
shell.IsVaultShowing.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");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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 ----
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue();
vault.GenerateComment = "deploy@laptop";
vault.ShowsKnownHosts.ShouldBeTrue();
vault.ShowsAll.ShouldBeFalse();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowsCredentials.ShouldBeFalse();
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();
}
/// <remarks>
/// A machine with no clipboard reports itself rather than appearing to have copied. This shell is built
/// without one, which is what makes the case reachable at all.
/// </remarks>
[Fact]
public async Task CopyingAPublicKey_WithNoClipboard_SaysSo()
{
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);
vault.Status.ShouldContain("no clipboard", Case.Insensitive);
}
// ---- Importing ssh_config ----
/// <remarks>
/// 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.
/// </remarks>
[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);
}
[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);
}
// ---- Filtering the host sidebar ----
@@ -2245,6 +2706,403 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
}
// ---- Groups ----
/// <remarks>
/// 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.
/// </remarks>
[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<SidebarGroupHeader>().Label.ShouldBe("production");
rows[1].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("prod-db");
rows[2].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("UNGROUPED");
rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
}
/// <remarks>
/// 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.
/// </remarks>
[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<HostRowViewModel>().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<SidebarGroupHeader>().First());
vault.SidebarRows.OfType<HostRowViewModel>().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<HostRowViewModel>().ShouldBeEmpty("and a reload does not unfold it");
}
/// <remarks>
/// The heading is a row in the same <c>ListBox</c> 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.
/// </remarks>
[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<SidebarGroupHeader>().First();
vault.SelectedHost.ShouldBeSameAs(host);
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
}
/// <remarks>
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
/// that resolves to nothing. "The group is gone" and "this host is in no group" have to look the same,
/// because to the person reading the list they are the same thing.
/// </remarks>
[Fact]
public async Task DeletingAGroup_LeavesItsHostsUnderTheUngroupedHeading()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var groupId = vault.Groups.Single().EntityId;
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().Usage
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Groups.ShouldBeEmpty();
vault.HasGroups.ShouldBeFalse();
// The host keeps the id, which is what makes this cheap; the sidebar is what resolves it to nothing.
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
}
/// <remarks>
/// The picker keeps a placeholder entry for a group the vault no longer has, 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.
/// </remarks>
[Fact]
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var groupId = vault.Groups.Single().EntityId;
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
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.SelectedGroup = vault.Groups.Single();
vault.EditGroupCommand.Execute(null);
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");
}
// ---- Snippets ----
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
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<SidebarGroupHeader>()
.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);
}
/// <summary>Files a host into a group the way a user can: through the host's own editor.</summary>
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);
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;