Files
DodoSSH/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
T
jaap-jan c882fa0cd3 Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
2026-08-06 09:15:37 +02:00

6788 lines
268 KiB
C#

using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
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.Sync;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class ShellFlowTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
/// <summary>Everything this shell has copied, newest last.</summary>
private readonly List<string> clipboard = [];
/// <remarks>
/// 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.
/// </remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
/// <remarks>
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
/// the connect path reachable.
/// </remarks>
private readonly FakeSshConnectionFactory ssh = new();
private int signInAttempts;
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
/// <remarks>
/// 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.
/// </remarks>
private int resumeAttempts;
private string? resumedWith;
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
private Exception? resumeFailure;
private string directory = null!;
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
/// <remarks>
/// 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.
/// </remarks>
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
/// <inheritdoc />
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<string, TerminalAsset>(StringComparer.Ordinal)
{
["/terminal"] = new(
"text/html; charset=utf-8",
System.Text.Encoding.UTF8.GetBytes(
$"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
}),
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;
});
return ValueTask.CompletedTask;
}
/// <inheritdoc />
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);
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// Separate from the case above because it is not caught by the same check. <c>Uri.TryCreate</c>
/// accepts this happily as an absolute URI whose <em>scheme</em> 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.
/// </remarks>
[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");
}
/// <remarks>
/// <para>
/// 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: <c>https://localhost:7217</c>, the API's second launch profile, and then the first
/// profile's <c>http://localhost:5233</c> in a release build, which is a machine an installed
/// application is not running.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <summary>
/// The whole point of the push channel: a pass that did not wait for the minute.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}
/// <remarks>
/// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
/// losing: <c>PeriodicTimer</c> 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.
/// </remarks>
[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");
}
}
/// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
/// <remarks>
/// 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.
/// </remarks>
private static async Task EventuallyAsync(Func<bool> 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);
}
/// <remarks>
/// <para>
/// The page's own <c>term.focus()</c> 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 <c>NativeKeyboardFocus</c> for why an
/// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
/// </para>
/// <para>
/// 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 <em>not</em> 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.
/// </para>
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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.
/// <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.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);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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<SshPasswordCredential>().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);
}
/// <remarks>
/// <c>ssh</c> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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 <c>TryBuildAuthentication</c> exists to be the only copy of.
/// </remarks>
[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();
}
/// <remarks>
/// <b>The retry used to re-run whichever host was selected.</b> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <summary>One row of the connection log, built by hand.</summary>
/// <remarks>
/// 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 <c>DodoSSH.Client.Session.Tests</c> already does.
/// </remarks>
private static ConnectionLogRowViewModel Recent(string label, string address, Guid? hostId) =>
new(
new VaultItem<ConnectionLogSecret>(
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);
/// <remarks>
/// 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 <c>MainWindowViewModel.IsTerminalShowing</c>.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <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();
}
// ---- 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.
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// The other kind of not-connecting. An unknown host key is a question drawn on the hosts screen rather
/// than a failure, so the tab goes and the window is put back where the question is — a tab saying the
/// connection failed would be competing with the prompt that is about to resume it.
/// </remarks>
[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();
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn there, and it has to be reachable");
// 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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <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.Keychain);
LitEntries().ShouldBe(1);
shell.IsKeychainShowing.ShouldBeTrue();
int LitEntries() => new[]
{
shell.IsHostsShowing,
shell.IsTransfersShowing,
shell.IsKeychainShowing,
shell.IsVaultsShowing,
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()
{
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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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".
/// </remarks>
[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 ----
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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
/// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
/// any change of the selected <em>row</em> 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// The one connect test that deliberately attaches no <see cref="FakeRenderer"/>: 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.
/// </remarks>
[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);
}
/// <summary>
/// The deliberate half of what Lock does: the vault closes, the shells do not.
/// </summary>
/// <remarks>
/// <para>
/// 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. <c>MainWindowViewModel.LockAsync</c> carries the full argument.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Fact]
public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
{
await UnlockedAsync();
// Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
// satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
// not how it came to exist, and going through the gate would only add a way for this to fail.
await workspace.OpenSessionAsync(
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
TerminalSize.Default,
Token);
workspace.LiveSessionCount.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull("the vault's keys are gone");
workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");
shell.HasLiveSessions.ShouldBeTrue();
shell.LiveSessionCount.ShouldBe(1);
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
// And it survives the unlock too, so the session outlives the whole cycle rather than merely
// outliving the disposal.
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked);
workspace.LiveSessionCount.ShouldBe(1);
}
[Fact]
public async Task LockingWithNoOpenShells_DisclosesNothing()
{
await UnlockedAsync();
await shell.LockCommand.ExecuteAsync(null);
shell.LiveSessionCount.ShouldBe(0);
shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
}
// ---- SSH keys ----
[Fact]
public async Task AddingAKey_ShowsItImmediatelyAndPushesIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
vault.KeyEditorPassphrase = "hunter2";
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse();
var row = vault.Keys.ShouldHaveSingleItem();
row.Label.ShouldBe("deploy");
row.Description.ShouldBe("passphrase · no public half");
row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
vault.PendingChanges.ShouldBe(0);
server.LiveRowCount.ShouldBe(1, "a saved key should reach the server without pressing Sync");
// And the host list is untouched, so the two lists are genuinely separate.
vault.Hosts.ShouldBeEmpty();
}
[Fact]
public async Task AddingAKey_BindsItToNothing()
{
// Storing a key must not change how any host authenticates. The failure this rules out is a key
// nobody chose being offered to a host — a credential leaving the vault by accident.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
vault.Hosts[0].Authentication.ShouldBe("password");
await vault.SyncCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
}
[Fact]
public async Task EditingAKey_RoundTripsThroughTheEditorIncludingTheMaterial()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await vault.SyncCommand.ExecuteAsync(null);
vault.SelectedKey = vault.Keys[0];
vault.EditSelectedKeyCommand.Execute(null);
// The material has to come back into the editor. The codec has no partial update, so a save
// re-encodes every field — an editor that loaded a blank private key would erase it.
vault.KeyEditorLabel.ShouldBe("deploy");
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("MATERIAL"));
vault.KeyEditorPassphrase.ShouldBe("hunter2");
vault.KeyEditorNotes = "rotate quarterly";
await vault.SaveKeyCommand.ExecuteAsync(null);
await vault.SyncCommand.ExecuteAsync(null);
var saved = vault.Keys.ShouldHaveSingleItem().Key;
saved.Notes.ShouldBe("rotate quarterly");
saved.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"));
saved.Passphrase.ShouldBe("hunter2");
}
[Fact]
public async Task CancellingTheKeyEditor_LeavesNoMaterialBehindInIt()
{
// The editor holds a private key in a bound property for as long as it is open. It cannot be wiped
// — see SshKeySecret — but it can stop being referenced, and an abandoned editor that kept the key
// would hand it to whatever opened next.
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = PrivateKey("ABANDONED");
vault.KeyEditorPassphrase = "hunter2";
vault.CancelKeyEditCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
vault.KeyEditorPrivateKey.ShouldBeEmpty();
vault.KeyEditorPassphrase.ShouldBeEmpty();
vault.KeyEditorLabel.ShouldBeEmpty();
vault.Keys.ShouldBeEmpty();
}
[Fact]
public async Task APublicKeyPastedIntoThePrivateField_NamesTheActualMistake()
{
// ssh-keygen writes two files whose names differ by four characters. The message has to say which
// one to pick, because the alternative is an authentication failure at connect time that says
// nothing about the file.
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop";
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain(".pub");
vault.Keys.ShouldBeEmpty();
vault.IsEditingKey.ShouldBeTrue("the editor stays open so the paste can be corrected");
}
[Fact]
public async Task DeletingAKey_RemovesItLocallyAndPushesTheTombstone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
vault.SelectedKey = vault.Keys[0];
await DeleteSelectedKeyAsync(vault);
vault.Keys.ShouldBeEmpty();
server.LiveRowCount.ShouldBe(0);
vault.PendingChanges.ShouldBe(0);
}
// ---- One kind of item at a time ----
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// One editor open at a time <em>within</em> the vault screen, still — but no longer across it and the
/// Hosts screen. Both editors used to be <c>Auto</c> 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.
/// </remarks>
[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"));
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
// ---- 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<SshPasswordCredential>()
.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<SshPrivateKeyCredential>();
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<SshPrivateKeyCredential>()
.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<SshPasswordCredential>();
}
// ---- 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);
}
/// <remarks>
/// A reload must not <em>invent</em> 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.
/// </remarks>
[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.");
}
/// <remarks>
/// 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.
/// </remarks>
[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<SshPasswordCredential>().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<SshPasswordCredential>().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<SshPasswordCredential>().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");
}
/// <remarks>
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> 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.
/// </remarks>
[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);
}
/// <remarks>
/// A key and a credential the user has named the same thing is the ordinary case, not a contrived one — a
/// key called <c>deploy</c> and the deploy account's password. Without the qualifier the picker offers two
/// identical rows that authenticate completely differently.
/// </remarks>
[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"]);
}
/// <remarks>
/// 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.
/// </remarks>
[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 ----
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <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 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");
}
/// <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.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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// ◆ <b>The one secret this application deliberately offers to the clipboard.</b> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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 ----
/// <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);
}
/// <remarks>
/// ◆ <b>The tick, and the half of it that matters is what happens with it off.</b> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// <para>
/// One vault key per file, however many entries named it — the shape a real <c>ssh_config</c> actually
/// has. Twelve copies of one private key would be twelve things to rotate and eleven to forget, which is
/// the argument <c>HostSecret.SshKeyId</c> already makes for referencing a key rather than embedding it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <summary>
/// A <c>~/.ssh</c> holding a config that names a key, and the key beside it.
/// </summary>
/// <remarks>
/// The key path is written into the config as an absolute one, which is what
/// <c>SshConfigResolver.ExpandHome</c> produces for a real <c>~/.ssh/id_ed25519</c> — the tests cannot
/// use a tilde, since that would resolve against the profile of whoever is running them.
/// </remarks>
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);
}
// ---- Filtering the host sidebar ----
/// <remarks>
/// The filter's own list is a projection over <see cref="VaultViewModel.Hosts"/>, not the bound source
/// of the sidebar's selection — but <c>HostSidebar</c>'s <c>ListBox</c> two-way binds
/// <c>SelectedItem</c> to <see cref="VaultViewModel.SelectedHost"/> 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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 <c>ReloadHostsAsync</c> 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.
/// </remarks>
[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 ----
/// <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>
/// <para>
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
/// machines through the editor is thirty rounds of open, pick, save.
/// </para>
/// <para>
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
/// read that property, and none of them should be aimed at a card that has left the screen. See
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
/// </para>
/// </remarks>
[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;
var host = vault.Hosts.Single();
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
// 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<SidebarGroupHeader>()
.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.OpenGroupCommand.Execute(vault.Groups.Single());
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
// And back out again, which is what the host's own editor is for now that the drop has one target.
vault.OpenGroupCommand.Execute(null);
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
// Coming out of a group is the direction that lands the host back on this level, so here the
// selection does survive the move.
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <summary>
/// The grid holds one level of the tree, the way a directory pane holds one directory.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<HostRowViewModel>().Select(row => row.Label)
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
vault.OpenGroupCommand.Execute(null);
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
}
/// <remarks>
/// 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.
/// </remarks>
[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.OpenGroupCommand.Execute(
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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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 the user
/// is standing in, and the picker shows it before anything is saved.
/// </remarks>
[Fact]
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddGroupAsync(vault, "production");
vault.OpenGroupCommand.Execute(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");
}
/// <remarks>
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
/// one is open would be a save nobody asked for, and one they could then not cancel.
/// </remarks>
[Fact]
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
vault.SelectedHost = vault.Hosts.Single();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
vault.Status.ShouldContain("editing");
}
/// <remarks>
/// <para>
/// Deleting a group leaves the machines under it alone <em>and</em> 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.
/// </para>
/// <para>
/// The tick is deliberately not touched here, which is the point of the assertions: the default answer
/// is the one that keeps the machines.
/// </para>
/// </remarks>
[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.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
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);
}
/// <remarks>
/// 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.
/// </remarks>
[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.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
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);
}
/// <remarks>
/// 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.
/// </remarks>
[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.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.DeleteGroupCommand.Execute(null);
vault.DeletionTakesTheHostsToo = true;
vault.CancelDeleteCommand.Execute(null);
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
vault.DeleteGroupCommand.Execute(null);
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
}
/// <remarks>
/// 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.
/// </remarks>
[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.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.PendingDeletion.ShouldBeNull("the question was never put");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
vault.Status.ShouldContain("editing");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <em>here</em> 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.
/// </para>
/// </remarks>
[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.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");
}
// ---- 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.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
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");
}
/// <remarks>
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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<SshPasswordCredential>();
vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
}
/// <remarks>
/// 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.
/// </remarks>
[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.
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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 <c>VaultSharingTests</c>' job, which is where a second vault exists.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// ◆ <b>"Connect via SFTP", which crosses from the vault to the shell.</b> 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.
/// </remarks>
[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");
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
{
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
// selection bounces back to the host on purpose. The command has to work off the heading it was
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
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");
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.EditGroupFromHeadingCommand.Execute(heading);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
}
/// <remarks>
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
/// nothing on screen to say where it had gone, or how to get it back.
/// </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<SidebarGroupHeader>().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<SidebarGroupHeader>()
.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();
}
/// <remarks>
/// <para>
/// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at
/// all. <c>DeleteGroup</c> and <c>MoveGroup</c> aim at <c>GroupTarget</c>, 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.
/// </para>
/// <para>
/// The tick is asserted <em>off</em> 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.
/// </para>
/// </remarks>
[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<SidebarGroupHeader>().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");
}
/// <remarks>
/// 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.
/// </remarks>
[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<SidebarGroupHeader>().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<SidebarGroupHeader>()
.Single(row => row.GroupId is null);
vault.OpenGroupSheetCommand.Execute(ungrouped);
vault.GroupSheet.ShouldBeNull();
}
// ---- Deleting a host, from the phone's bar ----
/// <remarks>
/// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> 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.
/// </remarks>
[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);
}
[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");
}
[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);
}
/// <summary>Puts a tag on a host the way a user can: through the host's own editor.</summary>
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.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
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.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
}
// ---- 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;
/// <summary>Armoured material of a plausible shape, and deliberately not a usable key.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
private Task<IVaultServer> 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<IVaultServer>(failure)
: Task.FromResult<IVaultServer>(server);
}
/// <remarks>
/// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint
/// and nothing else. <see cref="resumeFailure"/> stands in for a provider that refuses.
/// </remarks>
private Task<IVaultServer> ResumeAsync(
Uri serverUrl,
string refreshToken,
CancellationToken cancellationToken)
{
resumeAttempts++;
resumedWith = refreshToken;
return resumeFailure is { } failure
? Task.FromException<IVaultServer>(failure)
: Task.FromResult<IVaultServer>(server);
}
/// <summary>
/// A second shell over the same profile directory, as a relaunch of the application is.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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();
}
/// <remarks>
/// The phone's case, tested here because the seam is the shell's. <c>Environment.MachineName</c> answers
/// <c>localhost</c> 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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".
/// </remarks>
[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 ----
/// <remarks>
/// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been
/// set up launched <em>offline</em> 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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 <c>LockAsync</c> 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.
/// </remarks>
[Fact]
public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo()
{
await UnlockedAsync();
await workspace.OpenSessionAsync(
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
TerminalSize.Default,
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 ----
/// <remarks>
/// 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.
/// </remarks>
[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"]);
}
/// <remarks>
/// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> 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.
/// </remarks>
[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();
}
/// <remarks>
/// 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.
/// </remarks>
[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"]);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The assertion that matters is the last pair: pressing the button lands on the keychain <em>with the
/// editor already open</em>. Navigating to the screen and leaving the user to find + BUCKET among five
/// buttons would be most of the same problem.
/// </para>
/// </remarks>
[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);
}
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
/// <remarks>
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> 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.
/// </remarks>
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
{
vault.DeleteHostCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <inheritdoc cref="DeleteSelectedHostAsync" />
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
{
vault.DeleteKeyCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <inheritdoc cref="DeleteSelectedHostAsync" />
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
{
vault.DeleteCredentialCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
}
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
/// <summary>Points a host at a stored credential through the same picker.</summary>
private static Task BindCredentialAsync(VaultViewModel vault, HostRowViewModel host, Guid credentialId) =>
BindAsync(vault, host, AuthenticationKind.Credential, credentialId);
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Connects with a renderer attached, which the data plane requires before a session opens.</summary>
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);
}
/// <summary>
/// The same, for a connection that is expected to store its password.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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");
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
return vault;
}
}