using DodoSSH.Client.App.ViewModels;
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.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
///
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
///
///
/// Runs with no Avalonia, no browser and no identity provider, because the view models are plain
/// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
/// rather than by remembering to click through them.
///
public sealed class ShellFlowTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
///
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
/// state machine, not about how expensive the passphrase is to attack.
///
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
///
/// The real factory would need a reachable sshd, which DodoSSH.Client.Ssh.Tests covers against
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
/// the connect path reachable.
///
private readonly FakeSshConnectionFactory ssh = new();
private int signInAttempts;
private string directory = null!;
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
///
/// A fake rather than the real TPM-backed store, and not for speed: the real one prompts for a Windows
/// consent dialog on every save and every load, so a suite using it would block forever waiting for
/// somebody to enter a PIN. What the shell has to get right is which buttons appear and what happens when
/// one is pressed, and that is exactly what a fake keystore can answer.
///
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
///
public ValueTask InitializeAsync()
{
// A real directory and a real SQLite file, because the production path is what StartAsync runs and
// an in-memory database would skip the migration that creates the file.
directory = Path.Combine(Path.GetTempPath(), $"dodossh-shell-{Guid.CreateVersion7():N}");
paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
// The real store, not a stand-in. It is the one the application composes, its lifecycle is this
// shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault
// this suite already has, so substituting one would only stop the wiring being tested.
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
// resource system at construction and needs an initialised toolkit. This is what
// ITerminalAssetProvider is for; nothing in this suite renders anything.
//
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
// reading them back out of the served page rather than by being handed the token.
//
// The renderer timeout is cut right down for the same reason: the tests that want a renderer attach
// one themselves, so a connect in a test that does not would wait the shipped fifteen seconds out
// in full, and that is fifteen seconds of a suite sitting still. A second rather than milliseconds
// because this bounds FakeRenderer's own wait too — an in-process loopback handshake that has
// already returned, so the margin is enormous, but not one worth making a loaded machine race for.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary(StringComparer.Ordinal)
{
["/terminal"] = new(
"text/html; charset=utf-8",
System.Text.Encoding.UTF8.GetBytes(
$"")),
}),
ssh,
TimeProvider.System,
new TerminalWorkspaceOptions { RendererTimeout = TimeSpan.FromSeconds(1) });
// Started, as the application does immediately after composing it. Without the accept loop the
// page is never served, so nothing could attach a renderer.
workspace.Start();
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
SignInAsync,
TimeProvider.System,
CheapProfile);
return ValueTask.CompletedTask;
}
///
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task AFreshMachine_AsksForAServer()
{
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.NeedsServer);
shell.IsNeedingServer.ShouldBeTrue();
shell.IsOnline.ShouldBeFalse();
// The migration ran, so the file exists before anyone has signed in to anything.
File.Exists(paths.CacheFile).ShouldBeTrue();
}
[Fact]
public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsEnrollment);
shell.IsOnline.ShouldBeTrue();
shell.AccountName.ShouldBe("Alice Example");
}
[Fact]
public async Task AnUnreachableServer_ReportsAndStaysPut()
{
server.SignInFailure = new HttpRequestException("No such host is known.");
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsServer);
shell.StatusMessage.ShouldContain("No such host");
shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled");
}
[Fact]
public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork()
{
await shell.StartAsync(Token);
shell.ServerUrl = "not a url";
await shell.SignInCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsServer);
shell.IsOnline.ShouldBeFalse();
}
///
/// Separate from the case above because it is not caught by the same check. Uri.TryCreate
/// accepts this happily as an absolute URI whose scheme is "localhost" and whose host is
/// empty, so without an explicit scheme check the mistake surfaces much later as something that reads
/// like a network fault.
///
[Fact]
public async Task AServerUrlWithNoScheme_SaysSoRatherThanFailingLater()
{
await shell.StartAsync(Token);
shell.ServerUrl = "localhost:5233";
await shell.SignInCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsServer);
shell.IsOnline.ShouldBeFalse();
shell.StatusMessage.ShouldContain("http://");
signInAttempts.ShouldBe(0, "a malformed URL must not open a browser");
}
///
/// The shipped default is a value a user is invited to accept unread, so it is worth one assertion.
/// It was https://localhost:7217 — the API's second launch profile — while the README, the
/// API's appsettings and a plain dotnet run all use HTTP on 5233, and pointing an HTTPS client
/// at a plaintext port reports a TLS failure that reads like a certificate problem. Nothing failed
/// except the first thing a new user does.
///
[Fact]
public void TheDefaultServerUrl_IsTheAddressTheApiActuallyServes()
{
shell.ServerUrl.ShouldBe("http://localhost:5233");
}
[Theory]
[InlineData("short", "short")]
[InlineData("a sufficiently long passphrase", "a different one")]
public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
{
await SignedInAsync();
shell.Passphrase = entered;
shell.ConfirmPassphrase = confirmation;
await shell.EnrollCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsEnrollment);
server.EnrollmentCount.ShouldBe(0);
}
[Fact]
public async Task TheRecoveryCodeScreen_CannotBeSkipped()
{
// The only moment the code exists. Losing it along with the passphrase means the vault is
// unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
await EnrolledAsync();
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
// Trying to continue without confirming gets nowhere.
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
shell.RecoveryCode.ShouldNotBeNull();
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.State.ShouldBe(ShellState.Locked);
// And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
// model for the rest of the session would undo that.
shell.RecoveryCode.ShouldBeNull();
}
[Fact]
public async Task AWrongPassphrase_KeepsTheVaultLocked()
{
await ReadyToUnlockAsync();
shell.Passphrase = "not the passphrase";
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull();
shell.StatusMessage.ShouldContain("did not open");
}
[Fact]
public async Task UnlockingOpensTheVault()
{
await UnlockedAsync();
shell.State.ShouldBe(ShellState.Unlocked);
shell.Vault.ShouldNotBeNull();
shell.Vault.VaultName.ShouldBe("Personal");
// Cleared once used, so it is not sitting in a bound property for the rest of the session.
shell.Passphrase.ShouldBeEmpty();
}
[Fact]
public async Task ARestartUnlocksWithNoNetworkAtAll()
{
// The property the whole storage layer exists for, from the shell's point of view. The second
// shell is given a sign-in delegate that fails if called.
await EnrolledAndConfirmedAsync();
await shell.LockCommand.ExecuteAsync(null);
var offline = new MainWindowViewModel(
paths,
caches,
workspace,
new VaultKnownHostStore(),
new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
TimeProvider.System,
CheapProfile);
await using var _ = offline.ConfigureAwait(false);
await offline.StartAsync(Token);
offline.State.ShouldBe(ShellState.Locked);
offline.AccountName.ShouldBe("Alice Example");
offline.IsOnline.ShouldBeFalse();
offline.Passphrase = Passphrase;
await offline.UnlockCommand.ExecuteAsync(null);
offline.State.ShouldBe(ShellState.Unlocked);
offline.Vault.ShouldNotBeNull();
}
///
/// This used to assert the opposite of its last two lines — that a save queued the change and pushed
/// nothing until Sync was pressed. Saving now pushes, so the assertion had to move rather than be
/// deleted: the local-first guarantee it was really protecting is that the list updates without a
/// server, and that is still covered by the offline test below.
///
[Fact]
public async Task AddingAHost_ShowsItImmediatelyAndPushesIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorPort = 2222;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse();
var row = vault.Hosts.ShouldHaveSingleItem();
row.Label.ShouldBe("prod-db");
row.Address.ShouldBe("deploy@db.internal:2222");
row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
row.Badge.ShouldBeEmpty();
vault.PendingChanges.ShouldBe(0);
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
}
[Fact]
public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.Status = "Reading something the user cares about.";
await vault.AutoSyncAsync(Token);
vault.Status.ShouldBe(
"Reading something the user cares about.",
"a background pass with no changes must not repaint the status line");
vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag");
}
///
/// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a
/// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending
/// change left and the count was unchanged whether the guard existed or not. It passed with the guard
/// deleted, which is the only reason it was noticed.
///
[Fact]
public async Task AnAutomaticPass_YieldsWhileACommandIsRunning()
{
await UnlockedAsync();
var vault = shell.Vault!;
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
await AddHostAsync(vault, "prod-db");
server.SyncFailure = null;
vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");
var pushesBefore = server.PushCount;
// Standing in for a command in flight. A pass that pushed here would be competing with whatever
// the user is doing for the same session and the same cache.
vault.IsBusy = true;
await vault.AutoSyncAsync(Token);
server.PushCount.ShouldBe(pushesBefore, "the pass should have been skipped, not queued");
}
///
/// The behaviour a background loop lives or dies by. A pass runs every minute; one that reported a
/// transient server error would replace whatever the user was reading, once a minute, indefinitely.
///
[Fact]
public async Task AnAutomaticPassThatFails_LeavesTheStatusAlone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.Status = "Reading something the user cares about.";
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
await vault.AutoSyncAsync(Token);
vault.Status.ShouldBe("Reading something the user cares about.");
vault.IsBusy.ShouldBeFalse();
// And pressing Sync still reports the real reason, so the failure is quiet rather than hidden.
await vault.SyncCommand.ExecuteAsync(null);
vault.Status.ShouldContain("bad day");
}
///
///
/// The page's own term.focus() focuses the textarea inside the document, which does nothing
/// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
/// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
/// first. The view hands the control focus when this fires; see NativeKeyboardFocus for why an
/// ordinary Focus() call is enough in that direction and not in the other.
///
///
/// What this covers is the plumbing that carries the fix: that the raise is on the success path and
/// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
/// build error — the event would be unused, and warnings are errors — but moving it, which is the
/// likelier mistake, is not. It does not cover the focus call itself: that needs a native
/// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
/// tests here before. Measured separately in a harness; see docs/platform-flags.md.
///
///
[Fact]
public async Task ConnectingAsksTheViewToFocusTheTerminal()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
requests.ShouldBe(1);
// Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
// already true would not move focus to the terminal the user just opened.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(2);
}
///
/// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
/// buttons, and taking it into a terminal that has no session would strand the decision.
///
[Fact]
public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
requests.ShouldBe(0);
}
[Fact]
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:first-contact"));
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
// The second connection is the one that succeeds, which is what a trust-and-retry actually is: the
// handshake is refused, the user decides, and a fresh connection is made with the pin in place.
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeFalse();
// Two connection attempts: the one that was refused and the one the pin allowed. Asserted on the
// factory rather than on the status line, which the push that follows a trust legitimately repaints.
ssh.Requests.Count.ShouldBe(2);
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token))
.ShouldBe("SHA256:first-contact");
// Pushed as part of trusting, so the next machine to sync is not asked the same question.
vault.PendingChanges.ShouldBe(0);
}
[Fact]
public async Task APinnedHostKey_SurvivesLockingAndUnlocking()
{
// The gap this whole item closes, from the shell's point of view: the store is opened on unlock and
// its contents come out of the vault, so approving a fingerprint is a decision that lasts.
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:approved"));
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
await shell.LockCommand.ExecuteAsync(null);
// Locked means locked: the pins go with the vault keys, so nothing can answer a host key question
// while the window is showing an unlock screen.
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ForgettingAHostKey_ClearsThePinAndTheRefusal()
{
// The way back from a rebuilt server, and the reason a mismatch can stay a hard refusal: the user
// withdraws trust deliberately, from the host's own editor, rather than clicking past a warning.
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-old-key"));
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
// The server is rebuilt and offers something else.
ssh.Failure = new SshHostKeyMismatchException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
"SHA256:the-old-key");
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasHostKeyMismatch.ShouldBeTrue();
vault.EditSelectedHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeTrue();
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
// The refusal that sent the user here is about a pin that no longer exists, so it goes too.
vault.HasHostKeyMismatch.ShouldBeFalse();
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
// And the withdrawal was pushed rather than left for the timer: the other machines are the ones
// still refusing to connect to a server that has been rebuilt. The wording of the message is
// asserted in ForgettingAHostKeyThatWasNeverPinned_SaysSo, where no pass overwrites the status.
vault.PendingChanges.ShouldBe(0);
}
[Fact]
public async Task ForgettingAHostKeyThatWasNeverPinned_SaysSo()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Nothing was pinned");
}
[Fact]
public async Task ThereIsNothingToForgetOnAHostThatDoesNotExistYet()
{
// The button is hidden while a host is being created, because the pin belongs to an address that has
// not been saved anywhere yet.
var vault = await ReadyToConnectAsync();
vault.NewHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeFalse();
vault.CancelEditCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeFalse();
vault.EditSelectedHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeTrue();
}
///
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
///
[Fact]
public async Task LockingStopsTheShellForwardingFocusRequests()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
shell.Vault.ShouldBeNull();
// The discarded vault is detached, so even a late raise from it reaches nobody.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
}
[Fact]
public async Task AnInvalidHost_IsRefusedWithAReason()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = " ";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
vault.Hosts.ShouldBeEmpty();
vault.Status.ShouldContain("needs a name");
}
///
/// This is what earns the right to let a background pass fail silently. The automatic push after a save
/// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would
/// mean "lost".
///
[Fact]
public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync()
{
await UnlockedAsync();
var vault = shell.Vault!;
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
await AddHostAsync(vault, "prod-db");
server.LiveRowCount.ShouldBe(0, "the automatic push should have failed");
vault.PendingChanges.ShouldBe(1, "and the change should still be queued");
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue();
server.SyncFailure = null;
await vault.SyncCommand.ExecuteAsync(null);
server.LiveRowCount.ShouldBe(1);
vault.PendingChanges.ShouldBe(0);
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
vault.Status.ShouldContain("Synchronised");
}
[Fact]
public async Task EditingAHostRoundTripsThroughTheEditor()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await vault.SyncCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel.ShouldBe("prod-db");
vault.EditorHostname.ShouldBe("db.internal");
vault.EditorNotes = "rotate quarterly";
await vault.SaveHostCommand.ExecuteAsync(null);
await vault.SyncCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
}
[Fact]
public async Task DeletingAHost_RemovesItLocallyAndPushesTheTombstone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
await vault.DeleteHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldBeEmpty();
// Pushed without a second action. A tombstone that sat in the outbox would let the item come back
// on a machine that synced in the meantime.
server.LiveRowCount.ShouldBe(0);
vault.PendingChanges.ShouldBe(0);
}
[Fact]
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
{
await EnrolledAndConfirmedAsync();
await shell.LockCommand.ExecuteAsync(null);
// Locking does not drop the connection, so take a fresh shell that never signed in.
var offline = new MainWindowViewModel(
paths,
caches,
workspace,
new VaultKnownHostStore(),
new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("unreachable"),
TimeProvider.System,
CheapProfile);
await using var _ = offline.ConfigureAwait(false);
await offline.StartAsync(Token);
offline.Passphrase = Passphrase;
await offline.UnlockCommand.ExecuteAsync(null);
var vault = offline.Vault!;
await AddHostAsync(vault, "offline-host");
await vault.SyncCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Offline");
vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
server.PushCount.ShouldBe(0);
}
///
/// The one connect test that deliberately attaches no : the listener is up
/// and nothing ever connects to it, which from the view model's side is indistinguishable from a
/// WebView2 that failed to initialise on a user's machine. The interesting assertion is the second
/// one: while the wait was unbounded this hung with the busy flag set, so the window stayed disabled
/// and said "Connecting…" for the rest of the session.
///
[Fact]
public async Task ConnectingWithNoRenderer_ExplainsItselfAndReleasesTheWindow()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
await vault.ConnectCommand.ExecuteAsync(null);
// Naming the runtime is the whole point: a bare "The operation has timed out" sends someone
// looking at their network or their host.
vault.Status.ShouldContain("WebView2");
vault.IsBusy.ShouldBeFalse("a connect that gave up must not leave the window disabled");
}
[Fact]
public async Task LockingForgetsTheVault()
{
await UnlockedAsync();
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull();
// And unlocking again works, so locking released rather than corrupted anything.
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked);
}
///
/// The deliberate half of what Lock does: the vault closes, the shells do not.
///
///
///
/// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a
/// person does when they walk away from the machine, and that is exactly when a long job is most
/// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock
/// would do it unattended. MainWindowViewModel.LockAsync carries the full argument.
///
///
/// The disclosure is asserted along with the behaviour, because the two are the same decision. A
/// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while
/// authenticated SSH channels stay open is only defensible if it says so.
///
///
[Fact]
public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
{
await UnlockedAsync();
// Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
// satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
// not how it came to exist, and going through the gate would only add a way for this to fail.
await workspace.OpenSessionAsync(
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
TerminalSize.Default,
Token);
workspace.LiveSessionCount.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull("the vault's keys are gone");
workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");
shell.HasLiveSessions.ShouldBeTrue();
shell.LiveSessionCount.ShouldBe(1);
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
// And it survives the unlock too, so the session outlives the whole cycle rather than merely
// outliving the disposal.
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked);
workspace.LiveSessionCount.ShouldBe(1);
}
[Fact]
public async Task LockingWithNoOpenShells_DisclosesNothing()
{
await UnlockedAsync();
await shell.LockCommand.ExecuteAsync(null);
shell.LiveSessionCount.ShouldBe(0);
shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
}
// ---- SSH keys ----
[Fact]
public async Task AddingAKey_ShowsItImmediatelyAndPushesIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
vault.KeyEditorPassphrase = "hunter2";
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse();
var row = vault.Keys.ShouldHaveSingleItem();
row.Label.ShouldBe("deploy");
row.Description.ShouldBe("passphrase · no public half");
row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
vault.PendingChanges.ShouldBe(0);
server.LiveRowCount.ShouldBe(1, "a saved key should reach the server without pressing Sync");
// And the host list is untouched, so the two lists are genuinely separate.
vault.Hosts.ShouldBeEmpty();
}
[Fact]
public async Task AddingAKey_BindsItToNothing()
{
// Storing a key must not change how any host authenticates. The failure this rules out is a key
// nobody chose being offered to a host — a credential leaving the vault by accident.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
vault.Hosts[0].Authentication.ShouldBe("password");
await vault.SyncCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
}
[Fact]
public async Task EditingAKey_RoundTripsThroughTheEditorIncludingTheMaterial()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await vault.SyncCommand.ExecuteAsync(null);
vault.SelectedKey = vault.Keys[0];
vault.EditSelectedKeyCommand.Execute(null);
// The material has to come back into the editor. The codec has no partial update, so a save
// re-encodes every field — an editor that loaded a blank private key would erase it.
vault.KeyEditorLabel.ShouldBe("deploy");
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("MATERIAL"));
vault.KeyEditorPassphrase.ShouldBe("hunter2");
vault.KeyEditorNotes = "rotate quarterly";
await vault.SaveKeyCommand.ExecuteAsync(null);
await vault.SyncCommand.ExecuteAsync(null);
var saved = vault.Keys.ShouldHaveSingleItem().Key;
saved.Notes.ShouldBe("rotate quarterly");
saved.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"));
saved.Passphrase.ShouldBe("hunter2");
}
[Fact]
public async Task CancellingTheKeyEditor_LeavesNoMaterialBehindInIt()
{
// The editor holds a private key in a bound property for as long as it is open. It cannot be wiped
// — see SshKeySecret — but it can stop being referenced, and an abandoned editor that kept the key
// would hand it to whatever opened next.
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = PrivateKey("ABANDONED");
vault.KeyEditorPassphrase = "hunter2";
vault.CancelKeyEditCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
vault.KeyEditorPrivateKey.ShouldBeEmpty();
vault.KeyEditorPassphrase.ShouldBeEmpty();
vault.KeyEditorLabel.ShouldBeEmpty();
vault.Keys.ShouldBeEmpty();
}
[Fact]
public async Task APublicKeyPastedIntoThePrivateField_NamesTheActualMistake()
{
// ssh-keygen writes two files whose names differ by four characters. The message has to say which
// one to pick, because the alternative is an authentication failure at connect time that says
// nothing about the file.
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = "deploy";
vault.KeyEditorPrivateKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop";
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain(".pub");
vault.Keys.ShouldBeEmpty();
vault.IsEditingKey.ShouldBeTrue("the editor stays open so the paste can be corrected");
}
[Fact]
public async Task DeletingAKey_RemovesItLocallyAndPushesTheTombstone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
vault.Keys.ShouldBeEmpty();
server.LiveRowCount.ShouldBe(0);
vault.PendingChanges.ShouldBe(0);
}
// ---- One kind of item at a time ----
[Fact]
public async Task TheColumnOpensOnHostsAndTheSelectorMovesBetweenSections()
{
await UnlockedAsync();
var vault = shell.Vault!;
// Hosts, because connecting is what somebody who has just unlocked a vault came to do. Keys and
// credentials exist to make that work, and neither is where the first click belongs.
vault.Section.ShouldBe(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsKeys.ShouldBeTrue();
vault.ShowsHosts.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
}
///
/// The invariant that makes an unreachable editor impossible: an editor is only ever open in the section
/// that is showing. Without it, adding a key from a keyboard shortcut or a future menu would open an
/// editor nobody can see, holding a private key nobody can cancel.
///
[Fact]
public async Task OpeningAnEditorBringsItsOwnSectionIntoView()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
vault.Section = VaultSection.Hosts;
vault.NewKeyCommand.Execute(null);
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open in the hosts section");
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.ShowsHosts.ShouldBeTrue();
vault.CancelEditCommand.Execute(null);
// And through the other door into each editor.
vault.SelectedKey = vault.Keys[0];
vault.EditSelectedKeyCommand.Execute(null);
vault.ShowsKeys.ShouldBeTrue();
vault.CancelKeyEditCommand.Execute(null);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.ShowsHosts.ShouldBeTrue();
}
///
/// The refusal that keeps the rule above true. Leaving the section while an editor is open would hide it,
/// and in the key editor's case that means a pasted private key sitting in a form with nothing on screen
/// to say it is there.
///
[Fact]
public async Task SwitchingSectionIsRefusedWhileAnEditorIsOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
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.Hosts);
vault.ShowsHosts.ShouldBeTrue();
// Symmetrically, and with the message naming the editor that is actually open — which matters more
// here than it used to, because the thing to go back to may not be the section on screen.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsHosts.ShouldBeTrue();
vault.Status.ShouldContain("host");
vault.EditorLabel.ShouldBe("half-typed");
}
///
/// Asking for the section that is already showing is not a refusal, so a second click on the selected
/// button while an editor is open says nothing. Worth pinning because the obvious implementation — check
/// the editor, then compare — would scold somebody for clicking where they already are.
///
[Fact]
public async Task ReselectingTheSectionAlreadyShowingSaysNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.Status = string.Empty;
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.Status.ShouldBeEmpty();
vault.IsEditing.ShouldBeTrue();
}
///
/// One editor at a time, still — but no longer for the reason it was introduced for. Both editors used to
/// be Auto rows in one 340-pixel column whose combined height exceeded it; sections ended that, and
/// the layout suite now measures two open editors fitting. What the rule buys today is that an open key
/// editor is always one somebody can see, because it is holding their private key.
///
[Fact]
public async Task OnlyOneEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse("the host editor must not open over the key editor");
vault.IsEditingKey.ShouldBeTrue();
vault.Status.ShouldContain("SSH key");
// The refusal is worth nothing if it costs the paste.
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
// And it is a refusal, not a lockout.
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
// Symmetrically, with the host editor holding the column.
vault.EditorLabel = "half-typed";
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
vault.EditorLabel.ShouldBe("half-typed");
vault.Status.ShouldContain("host");
}
[Fact]
public async Task EditingAnExistingItem_IsRefusedByTheOtherEditorToo()
{
// The Edit commands are a second door into the same column, and guarding only the Add ones would
// leave it wide open.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
vault.SelectedHost = vault.Hosts[0];
vault.SelectedKey = vault.Keys[0];
vault.NewKeyCommand.Execute(null);
vault.EditSelectedHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse();
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.EditSelectedKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
}
// ---- Binding a key to a host ----
[Fact]
public async Task BindingAKeyToAHost_RoundTripsThroughTheEditorAndTheVault()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys[0].EntityId;
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
vault.Hosts[0].Authentication.ShouldBe("key");
// Through the server and back, which is what makes it a property of the host rather than of this
// machine — the whole reason it is a payload field and not a local preference.
await vault.SyncCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
// And it is offered back correctly when the editor reopens, including as the current selection.
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(keyId);
vault.EditorAuthenticationChoices[0].Kind
.ShouldBe(AuthenticationKind.Typed, "the typed-password entry stays first");
}
[Fact]
public async Task AHostWithNoKey_UsesThePassword()
{
var vault = await ReadyToConnectAsync();
// A key exists in the vault and is even selected in the key list. An unbound host must still use
// the password: the list selection is for editing keys, not for deciding authentication.
await AddKeyAsync(vault, "deploy");
vault.SelectedKey = vault.Keys[0];
vault.ConnectPassword = "typed-in";
await ConnectWithRendererAsync(vault);
ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType()
.Password.ShouldBe("typed-in");
}
[Fact]
public async Task AHostBoundToAKey_HandsTheSshStackTheKeyAndItsPassphrase()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.ConnectPassword = "should-not-be-used";
await ConnectWithRendererAsync(vault);
var credential = ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType();
System.Text.Encoding.UTF8.GetString(credential.PrivateKeyPem)
.ShouldBe(PrivateKey("MATERIAL"));
credential.Passphrase.ShouldBe("hunter2");
}
[Fact]
public async Task AKeyWithABlankPassphraseBox_IsAKeyWithNoPassphrase()
{
// Blank and absent are one state, from the editor all the way to the credential. The list has to say
// so too, because "passphrase" against a key that has none sends someone hunting for one they never
// set — and SSH.NET will not correct them: it ignores a passphrase on an unprotected key rather than
// refusing it. See docs/platform-flags.md.
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy", passphrase: string.Empty);
vault.Keys.ShouldHaveSingleItem().Description.ShouldStartWith("no passphrase");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
await ConnectWithRendererAsync(vault);
ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType()
.Passphrase.ShouldBeNull();
}
[Fact]
public async Task AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword()
{
// A key deleted on another machine is ordinary, and this is what it must not cause: a host somebody
// deliberately set up for key-only access quietly starting to offer a password instead.
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
vault.Keys.ShouldBeEmpty();
vault.SelectedHost = vault.Hosts[0];
vault.ConnectPassword = "must-not-be-sent";
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
vault.Status.ShouldContain("not in this vault");
}
[Fact]
public async Task EditingAHostWhoseKeyHasBeenDeleted_DoesNotQuietlyUnbindIt()
{
// The same failure one step removed, and the subtler one. Someone opens the host to change its port;
// if the picker had silently fallen back to "no key", saving would convert it to password
// authentication and nothing would ever have said so.
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys[0].EntityId;
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.SelectedKey = vault.Keys[0];
await vault.DeleteKeyCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
// The binding is still there, still selected, and says what is wrong with it.
var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
selected.EntityId.ShouldBe(keyId);
selected.Kind.ShouldBe(AuthenticationKind.SshKey, "a missing key must not come back as a credential");
selected.Label.ShouldContain("no longer here");
vault.EditorPort = 2244;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId, "an unrelated edit must not drop the binding");
}
[Fact]
public async Task RemovingABinding_PutsTheHostBackOnAPassword()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.Kind is AuthenticationKind.Typed);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
vault.Hosts[0].Authentication.ShouldBe("password");
vault.ConnectPassword = "typed-in";
await ConnectWithRendererAsync(vault);
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType();
}
// ---- Stored credentials ----
[Fact]
public async Task ACredentialRoundTripsThroughTheEditorAndTheVault()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
var row = vault.Credentials.ShouldHaveSingleItem();
row.Label.ShouldBe("prod deploy");
row.Description.ShouldBe("deploy", "the account is what the row has to show");
// Through the server and back, which is the whole point of storing it in the vault rather than on the
// machine that typed it.
await vault.SyncCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
vault.SelectedCredential = vault.Credentials[0];
vault.EditSelectedCredentialCommand.Execute(null);
vault.CredentialEditorPassword.ShouldBe(
"s3cret", "the editor has to load it, because saving re-encodes every field");
vault.CredentialEditorUsername.ShouldBe("deploy");
}
[Fact]
public async Task ACredentialWithNoUsername_SaysItUsesTheHosts()
{
// Blank and absent are one state — CredentialSecret normalises them — and the row has to say which of
// the two things a blank box means, because "no username" and "the host's username" are not the same
// statement and only one of them is true.
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "shared password", username: " ");
vault.Credentials.ShouldHaveSingleItem().Credential.Username.ShouldBeNull();
vault.Credentials[0].Description.ShouldBe("uses each host's own username");
}
[Fact]
public async Task ACredentialWithNoPassword_IsRefusedAndTheEditorStaysOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = "prod deploy";
await vault.SaveCredentialCommand.ExecuteAsync(null);
vault.Status.ShouldContain("password");
vault.Credentials.ShouldBeEmpty();
vault.IsEditingCredential.ShouldBeTrue("the editor stays open so it can be filled in");
}
[Fact]
public async Task CancellingTheCredentialEditor_LeavesNoPasswordBehindInIt()
{
// The same rule as the key editor, for the same reason: the editor holds the secret in a bound property
// for as long as it is open, and an abandoned editor that kept it would hand it to whatever opened next.
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = "prod deploy";
vault.CredentialEditorPassword = "ABANDONED";
vault.CancelCredentialEditCommand.Execute(null);
vault.IsEditingCredential.ShouldBeFalse();
vault.CredentialEditorPassword.ShouldBeEmpty();
vault.CredentialEditorLabel.ShouldBeEmpty();
vault.Credentials.ShouldBeEmpty();
}
[Fact]
public async Task DeletingACredential_RemovesItLocallyAndPushesTheTombstone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy");
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
vault.Credentials.ShouldBeEmpty();
server.LiveRowCount.ShouldBe(0);
vault.PendingChanges.ShouldBe(0);
}
///
/// A reload must not invent a selection, which is a safety property rather than a tidiness one:
/// Delete acts on the selection, so a list that fell back to its first row would put a one-click deletion of
/// somebody's password behind a button they never aimed. It does keep an existing selection, exactly as the
/// host list does — losing it on every background sync would move the target out from under the user.
///
[Fact]
public async Task ReloadingKeepsACredentialSelectionButNeverInventsOne()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "prod deploy");
// Saving selects what was just saved, which is wanted, and a reload has to leave it alone.
vault.SelectedCredential.ShouldNotBeNull();
await vault.LoadAsync(Token);
vault.SelectedCredential.ShouldNotBeNull();
// Nothing selected is the state Delete must find nothing in.
vault.SelectedCredential = null;
await vault.LoadAsync(Token);
vault.SelectedCredential.ShouldBeNull();
await vault.DeleteCredentialCommand.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 vault.DeleteCredentialCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
}
///
/// What decides whether the terminal column shows a password box at all. Three states and only one of them
/// wants typing, so this is the property that keeps a box from appearing on a host that has no use for one
/// — and keeps the sentence in its place from claiming the wrong reason.
///
[Fact]
public async Task ThePasswordBoxOnlyAppearsForAHostThatWillAskForOne()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "prod deploy");
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
vault.SelectedHostAuthenticationNote.ShouldBeEmpty();
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
vault.SelectedHostAuthenticationNote.ShouldContain("SSH key");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your vault");
}
// ---- Authenticating with a stored credential ----
[Fact]
public async Task AHostBoundToACredential_SendsItsPasswordAndItsUsername()
{
// Both halves, and the username is the half that was easy to lose: a credential's whole reason for
// existing is that it describes an account once, and sending its password under the host's username is
// wrong in a way the server only reports as "authentication failed".
//
// The credential's username is deliberately *not* the host's — the host is on "deploy" — because the
// two being equal is what makes this assertion pass under an implementation that reads the wrong one.
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "svc-deploy");
var credentialId = vault.Credentials[0].EntityId;
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credentialId);
vault.Hosts[0].Authentication.ShouldBe("credential");
vault.ConnectPassword = "should-not-be-used";
await ConnectWithRendererAsync(vault);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Credential.ShouldBeOfType().Password.ShouldBe("s3cret");
request.Username.ShouldBe("svc-deploy", "the credential's account overrides the host's");
}
[Fact]
public async Task ACredentialWithNoUsername_LeavesTheHostsInPlace()
{
// The shared-password case: one password used under whatever account each machine knows you by. The
// fallback is what makes that expressible at all, and getting it backwards would send every connection
// to the same account.
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "shared password", password: "s3cret");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
await ConnectWithRendererAsync(vault);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Username.ShouldBe("deploy", "the host's own username, which ReadyToConnectAsync sets");
request.Credential.ShouldBeOfType().Password.ShouldBe("s3cret");
}
[Fact]
public async Task AHostWithNoUsernameOfItsOwn_IsUsableThroughACredentialThatCarriesOne()
{
// The refusal for a host with no username used to run before anything looked at the binding, which made
// a credential's username unreachable in exactly the case it was most useful: a host somebody never
// filled a username in for.
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorUsername = string.Empty;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.Username.ShouldBeNull();
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
await ConnectWithRendererAsync(vault);
ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
}
[Fact]
public async Task AHostWithNoUsernameAndNoCredential_IsStillRefusedAndSaysWhereToPutOne()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorUsername = string.Empty;
await vault.SaveHostCommand.ExecuteAsync(null);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldContain("no username");
vault.Status.ShouldContain("credential", Case.Insensitive);
}
[Fact]
public async Task AHostWhoseCredentialHasBeenDeleted_RefusesRatherThanFallingBackToTheTypedPassword()
{
// The key case's twin, and it has to be its own test: the two branches are separate code, and the one
// that was written second is the one nothing would have covered.
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "prod deploy");
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
vault.SelectedCredential = vault.Credentials[0];
await vault.DeleteCredentialCommand.ExecuteAsync(null);
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 vault");
}
[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 vault.DeleteCredentialCommand.ExecuteAsync(null);
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");
}
///
/// The reason the picker is one control rather than two. HostSecret.TryValidate refuses a host naming
/// both a key and a credential, so two pickers would have been able to express the state and would have had
/// to reject it at save time; one picker cannot express it. This asserts the structural version of that —
/// that rebinding replaces rather than accumulates.
///
[Fact]
public async Task RebindingFromAKeyToACredential_ReplacesTheBindingRatherThanAddingToIt()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
var keyId = vault.Keys[0].EntityId;
var credentialId = vault.Credentials[0].EntityId;
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
var host = vault.Hosts.ShouldHaveSingleItem().Host;
host.CredentialId.ShouldBe(credentialId);
host.SshKeyId.ShouldBeNull("one picker means one binding");
host.TryValidate(out _).ShouldBeTrue();
// And back again, which is the direction that would leave a stale credential behind.
await BindKeyAsync(vault, vault.Hosts[0], keyId);
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);
}
///
/// A key and a credential the user has named the same thing is the ordinary case, not a contrived one — a
/// key called deploy and the deploy account's password. Without the qualifier the picker offers two
/// identical rows that authenticate completely differently.
///
[Fact]
public async Task ThePickerDistinguishesAKeyAndACredentialWithTheSameName()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "deploy");
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
var named = vault.EditorAuthenticationChoices
.Where(choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal))
.ToList();
named.Count.ShouldBe(2);
named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]);
}
[Fact]
public async Task TheCredentialEditorIsAlsoOneEditorAtATime()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorPassword = "half-typed";
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse("the host editor must not open over the credential editor");
vault.Status.ShouldContain("credential");
vault.CredentialEditorPassword.ShouldBe("half-typed");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsCredentials.ShouldBeTrue("and the selector must not move away from it either");
vault.CancelCredentialEditCommand.Execute(null);
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
}
// ---- Pinned host keys ----
///
/// The list that did not exist. Trust was created by the connect prompt and withdrawn from one host's
/// editor, so a pin for a host that had since been deleted or re-addressed was unreachable from the
/// interface entirely — it went on refusing connections, and nothing in the application would admit it
/// was there.
///
[Fact]
public async Task ThePinnedKeyList_ShowsWhatWasApprovedAndWhatNothingUsesAnyMore()
{
var vault = await ReadyToConnectAsync();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await knownHosts.TrustAsync(
new HostKeyPresentation("gone.internal", 22, "ssh-ed25519", "SHA256:another-key"), Token);
// Pushed, as the connect path pushes a pin the moment it is approved. Without this both rows would
// be badged "not synced", which is true and would drown out the badge this test is about.
await vault.SyncCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
vault.KnownHostPins.Count.ShouldBe(2);
var dialled = vault.KnownHostPins
.Single(pin => string.Equals(pin.Host, "db.internal", StringComparison.Ordinal));
dialled.IsDialledByAHost.ShouldBeTrue("ReadyToConnectAsync's host is at db.internal:22");
dialled.Fingerprint.ShouldBe("SHA256:the-key", "in full, because that is what gets compared");
dialled.Badge.ShouldBeEmpty();
var orphan = vault.KnownHostPins
.Single(pin => string.Equals(pin.Host, "gone.internal", StringComparison.Ordinal));
orphan.IsDialledByAHost.ShouldBeFalse();
orphan.Badge.ShouldBe("no host uses this");
}
[Fact]
public async Task DeletingAHost_LeavesItsPinBehindAndTheListSaysSo()
{
// The behaviour the debt was about, now visible instead of silent. Keeping the pin is right — the
// address may still be reached by something else, and trust is about the endpoint rather than the
// bookmark — so the fix was never to cascade the delete. It was to stop the leftover being invisible.
var vault = await ReadyToConnectAsync();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await vault.LoadAsync(Token);
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
vault.SelectedHost = vault.Hosts[0];
await vault.DeleteHostCommand.ExecuteAsync(null);
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();
}
[Fact]
public async Task ThePinSectionIsReachableAndTakesItsTurn()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.ShowsKnownHosts.ShouldBeTrue();
vault.ShowsHosts.ShouldBeFalse();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowsCredentials.ShouldBeFalse();
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// Armoured material of a plausible shape, and deliberately not a usable key.
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
private Task SignInAsync(Uri serverUrl, CancellationToken cancellationToken)
{
// Counted so a test can assert that a rejected URL never got this far. Reaching here means a
// browser would have opened in the real application.
signInAttempts++;
return server.SignInFailure is { } failure
? Task.FromException(failure)
: Task.FromResult(server);
}
private async Task SignedInAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.NeedsEnrollment);
}
private async Task EnrolledAsync()
{
await SignedInAsync();
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
}
private async Task EnrolledAndConfirmedAsync()
{
await EnrolledAsync();
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.State.ShouldBe(ShellState.Locked);
}
private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
// ---- 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();
}
[Fact]
public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
{
// The shell's half of the feature, end to end through the commands a user actually presses.
await UnlockedAsync();
await shell.RegisterDeviceCommand.ExecuteAsync(null);
shell.CanRegisterDevice.ShouldBeFalse("it is registered now, so the offer is spent");
server.RegisteredDevices.Count.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
await shell.StartAsync(Token);
shell.CanUnlockWithDevice.ShouldBeTrue("the wrap is cached and the keystore still has the key");
await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
shell.Passphrase.ShouldBeEmpty("nothing was typed");
}
[Fact]
public async Task WithdrawingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
{
// The button that makes revocation reachable at all. Until it existed, ForgetDeviceAsync had one
// caller and that caller was a test.
await UnlockedAsync();
await shell.RegisterDeviceCommand.ExecuteAsync(null);
shell.CanForgetDevice.ShouldBeTrue("there is a device key here now");
await shell.ForgetDeviceCommand.ExecuteAsync(null);
shell.CanForgetDevice.ShouldBeFalse("the offer is spent");
server.RegisteredDevices.ShouldBeEmpty("the account must not go on listing it");
await shell.LockCommand.ExecuteAsync(null);
await shell.StartAsync(Token);
shell.CanUnlockWithDevice.ShouldBeFalse("there is nothing left to unlock with");
// And the passphrase still opens it, which is what makes withdrawing safe to offer without a
// confirmation prompt.
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
///
/// The offer and the withdrawal are two flags rather than one and its negation, and this is why: on a
/// machine with no keystore both are false, and a single flag would have made "cannot register" mean
/// "has something to withdraw".
///
[Fact]
public async Task OnAMachineWithNoKeystore_ThereIsNothingToWithdrawEither()
{
deviceKeys.IsAvailable = false;
await UnlockedAsync();
shell.CanRegisterDevice.ShouldBeFalse();
shell.CanForgetDevice.ShouldBeFalse();
}
[Fact]
public async Task WithdrawingTheDeviceOffline_StopsThisMachineAndSaysTheAccountStillListsIt()
{
// Somebody who has just realised a machine is in the wrong hands may well be on a train. Refusing
// until they are online would leave the device unlocking itself for the whole journey.
await UnlockedAsync();
await shell.RegisterDeviceCommand.ExecuteAsync(null);
await shell.LockCommand.ExecuteAsync(null);
// The same keystore, so this machine still holds its device key — only the network is gone.
var offline = new MainWindowViewModel(
paths,
caches,
workspace,
new VaultKnownHostStore(),
deviceKeys,
(_, _) => throw new InvalidOperationException("The shell went to the network."),
TimeProvider.System,
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);
}
private async Task UnlockedAsync()
{
await EnrolledAndConfirmedAsync();
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
private static async Task AddHostAsync(VaultViewModel vault, string label)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
private static async Task AddKeyAsync(
VaultViewModel vault,
string label,
string material = "MATERIAL",
string passphrase = "hunter2")
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = label;
vault.KeyEditorPrivateKey = PrivateKey(material);
vault.KeyEditorPassphrase = passphrase;
await vault.SaveKeyCommand.ExecuteAsync(null);
}
/// Points a host at a key through the editor, the way a user would.
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
/// Points a host at a stored credential through the same picker.
private static Task BindCredentialAsync(VaultViewModel vault, HostRowViewModel host, Guid credentialId) =>
BindAsync(vault, host, AuthenticationKind.Credential, credentialId);
///
/// One helper for both because there is one control for both, and a test that reached for the binding a
/// different way than the interface does would stop covering the interface.
///
private static async Task BindAsync(
VaultViewModel vault,
HostRowViewModel host,
AuthenticationKind kind,
Guid entityId)
{
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue("the host editor has to be open for the picker to be populated");
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.Kind == kind && choice.EntityId == entityId);
await vault.SaveHostCommand.ExecuteAsync(null);
}
private static async Task AddCredentialAsync(
VaultViewModel vault,
string label,
string password = "s3cret",
string username = "")
{
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = label;
vault.CredentialEditorPassword = password;
vault.CredentialEditorUsername = username;
await vault.SaveCredentialCommand.ExecuteAsync(null);
}
/// Connects with a renderer attached, which the data plane requires before a session opens.
private async Task ConnectWithRendererAsync(VaultViewModel vault)
{
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
}
/// An unlocked vault with one selected host and a renderer attached.
private async Task ReadyToConnectAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
return vault;
}
}