Public Access
5465 lines
212 KiB
C#
5465 lines
212 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";
|
|
|
|
/// <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);
|
|
|
|
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");
|
|
}
|
|
|
|
/// <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.Vault);
|
|
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
shell.IsVaultShowing.ShouldBeTrue();
|
|
|
|
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
|
|
// its tab is.
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
tab.IsLive.ShouldBeTrue();
|
|
shell.SelectedTab.ShouldBe(tab);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClickingATab_BringsTheTerminalBackFromAPage()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <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.Vault);
|
|
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.Vault);
|
|
}
|
|
|
|
/// <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.Vault);
|
|
|
|
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.Vault);
|
|
|
|
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.Vault);
|
|
LitEntries().ShouldBe(1);
|
|
shell.IsVaultShowing.ShouldBeTrue();
|
|
|
|
int LitEntries() => new[]
|
|
{
|
|
shell.IsHostsShowing,
|
|
shell.IsTransfersShowing,
|
|
shell.IsVaultShowing,
|
|
shell.IsTeamShowing,
|
|
shell.IsPreferencesShowing,
|
|
}.Count(lit => lit);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The palette can be opened from any screen, and an unknown host key is answered by a prompt drawn on
|
|
/// the hosts screen. Without this the connection would block on a question sitting behind whatever screen
|
|
/// the user happened to be on.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingFromThePalette_LandsOnTheHostsPageBeforeItCanBeRefused()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
|
|
|
shell.ToggleSearchCommand.Execute(null);
|
|
shell.SelectedSearchResult = shell.SearchResults[0];
|
|
|
|
await shell.ConnectToSearchResultCommand.ExecuteAsync(null);
|
|
|
|
vault.HasPendingHostKey.ShouldBeTrue();
|
|
|
|
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn on the hosts screen");
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
|
|
{
|
|
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 TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
|
|
{
|
|
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 SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen()
|
|
{
|
|
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 TheHostEditorAndAVaultScreenEditorCanBeOpenTogether()
|
|
{
|
|
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 OnlyOneVaultScreenEditorOpensAtATime_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_IsRefusedByTheOtherVaultScreenEditorToo()
|
|
{
|
|
// 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 TheCredentialEditorGuardsTheVaultScreensRail()
|
|
{
|
|
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.IsVaultShowing.ShouldBeFalse();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know
|
|
/// whether it is the one they approved. A filter that searched only host names would answer a different
|
|
/// question, so this is the assertion that keeps the fingerprint in the search.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token);
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token);
|
|
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
var pins = shell.KnownHostsScreen!;
|
|
pins.VisiblePins.Count.ShouldBe(2);
|
|
|
|
pins.Filter = "bbbb";
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal");
|
|
|
|
pins.Filter = "db.";
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
|
|
|
pins.Filter = "nothing matches this";
|
|
pins.VisiblePins.ShouldBeEmpty();
|
|
pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push.
|
|
/// What this covers is the forwarding: that the screen's own selection reaches it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
var pins = shell.KnownHostsScreen!;
|
|
pins.Selected = pins.VisiblePins[0];
|
|
|
|
await pins.ForgetSelectedCommand.ExecuteAsync(null);
|
|
|
|
pins.VisiblePins.ShouldBeEmpty();
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
}
|
|
|
|
// ---- Generating a key ----
|
|
|
|
/// <remarks>
|
|
/// The property that keeps this feature from being a second way to write a key: generating fills the
|
|
/// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a
|
|
/// pasted key already takes, and SAVE is still the only thing that writes.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.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>
|
|
/// A machine with no clipboard reports itself rather than appearing to have copied. This shell is built
|
|
/// without one, which is what makes the case reachable at all.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CopyingAPublicKey_WithNoClipboard_SaysSo()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewGeneratedKeyCommand.Execute(null);
|
|
await vault.GenerateKeyCommand.ExecuteAsync(null);
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Section = VaultSection.Keys;
|
|
vault.SelectedVaultItem = vault.VaultItems[0];
|
|
vault.SelectedItemIsKey.ShouldBeTrue();
|
|
|
|
await vault.CopyPublicKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("no clipboard", Case.Insensitive);
|
|
}
|
|
|
|
// ---- Importing ssh_config ----
|
|
|
|
/// <remarks>
|
|
/// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the
|
|
/// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing
|
|
/// goes through the ordinary create-and-push path, and that a host already in the keychain arrives
|
|
/// unticked rather than being silently duplicated.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
var sshDirectory = Path.Combine(directory, "ssh");
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
// db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held.
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "config"),
|
|
"""
|
|
Host already-here
|
|
HostName db.internal
|
|
User deploy
|
|
|
|
Host web-01
|
|
HostName web-01.internal
|
|
User deploy
|
|
Port 2222
|
|
""",
|
|
Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.Rows.Count.ShouldBe(2);
|
|
vault.Hosts.Count.ShouldBe(1, "scanning stores nothing");
|
|
|
|
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
|
|
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
|
|
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
|
|
|
|
import.Rows
|
|
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
|
.IsSelected.ShouldBeTrue();
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
|
imported.Address.ShouldBe("deploy@web-01.internal:2222");
|
|
|
|
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
|
|
|
|
// Through the ordinary path, which is the point of routing it through the vault: it reached the
|
|
// server without anything pressing Sync.
|
|
server.LiveRowCount.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var import = new ImportViewModel(
|
|
shell.Vault!,
|
|
new SshConfigLocator(Path.Combine(directory, "nothing-here")));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.Rows.ShouldBeEmpty();
|
|
import.Status.ShouldContain("no", Case.Insensitive);
|
|
}
|
|
|
|
// ---- Filtering the host sidebar ----
|
|
|
|
/// <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>
|
|
/// 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.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
|
|
{
|
|
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.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
|
|
|
|
// Under the group's own heading now, which is what the phone's list draws.
|
|
vault.SidebarRows.OfType<SidebarGroupHeader>()
|
|
.Single(header => header.GroupId == group)
|
|
.Count.ShouldBe(1);
|
|
|
|
// And the name on the card, which is what the desktop's grid draws instead of that heading — the one
|
|
// thing on screen that changes where the host was dropped rather than where it came from.
|
|
vault.Hosts.Single().GroupLabel.ShouldBe("production");
|
|
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
|
|
|
// And back out again, which is what the host's own editor is for now that the drop has one target.
|
|
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");
|
|
}
|
|
|
|
/// <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>
|
|
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
|
|
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
|
|
/// that resolves to nothing. "The group is gone" and "this host is in no group" have to look the same,
|
|
/// because to the person reading the list they are the same thing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAGroup_LeavesItsHostsUnderTheUngroupedHeading()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var groupId = vault.Groups.Single().EntityId;
|
|
|
|
vault.SelectedGroup = vault.Groups.Single();
|
|
vault.DeleteGroupCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().Usage
|
|
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Groups.ShouldBeEmpty();
|
|
vault.HasGroups.ShouldBeFalse();
|
|
|
|
// The host keeps the id, which is what makes this cheap; the list is what resolves it to nothing.
|
|
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
|
|
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
|
|
|
// The card says the same thing the phone's list does: nothing. An id nobody can name is drawn as no
|
|
// group rather than as a GUID on a chip.
|
|
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The picker keeps a placeholder entry for a group the vault no longer has, exactly as the
|
|
/// authentication picker does for a deleted key. Without it the picker would open on "No group" and
|
|
/// somebody editing the host's port would unfile it by saving.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var groupId = vault.Groups.Single().EntityId;
|
|
|
|
vault.SelectedGroup = vault.Groups.Single();
|
|
vault.DeleteGroupCommand.Execute(null);
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts.Single();
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId);
|
|
|
|
vault.EditorPort = 2222;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RenamingAGroup_RenamesItsHeading()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.SelectedGroup = vault.Groups.Single();
|
|
vault.EditGroupCommand.Execute(null);
|
|
|
|
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
|
|
|
vault.GroupEditorLabel = "live";
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
Headings(vault).ShouldBe(["live"]);
|
|
vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
|
|
}
|
|
|
|
// ---- 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");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheConnectBar_GoesAwayWhileAnEditorIsUpRatherThanGreyingOut()
|
|
{
|
|
// The editors replace the list rather than floating over it, so a bar left in place would carry
|
|
// CONNECT and EDIT for a host that is no longer on screen — and under the host editor, for the very
|
|
// record being typed into. This was disabled rather than hidden first, which reads as a screen that
|
|
// has broken rather than one that is busy.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
|
|
vault.ShowsConnectBar.ShouldBeTrue();
|
|
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
vault.ShowsConnectBar.ShouldBeFalse("the sheet is over the list");
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.ShowsConnectBar.ShouldBeFalse("and the editor is in place of it");
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
vault.ShowsConnectBar.ShouldBeTrue("and it comes back with the list");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheConnectBar_StaysAwayWithNoHostChosen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = null;
|
|
|
|
vault.ShowsConnectBar.ShouldBeFalse();
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
|
|
[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"]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|