Public Access
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.
DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.
Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.
Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.
--- Two things found on the way ---
Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.
And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.
--- Reachable at all ---
ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.
No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.
Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.
The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.
Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.
930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2030 lines
78 KiB
C#
2030 lines
78 KiB
C#
using DodoSSH.Client.App.ViewModels;
|
|
using DodoSSH.Client.Session;
|
|
|
|
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
|
// see the csproj for why it is shared rather than reimplemented.
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <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;
|
|
|
|
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,
|
|
CheapProfile);
|
|
|
|
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 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>
|
|
/// The shipped default is a value a user is invited to accept unread, so it is worth one assertion.
|
|
/// It was <c>https://localhost:7217</c> — the API's second launch profile — while the README, the
|
|
/// API's appsettings and a plain <c>dotnet run</c> all use HTTP on 5233, and pointing an HTTPS client
|
|
/// at a plaintext port reports a TLS failure that reads like a certificate problem. Nothing failed
|
|
/// except the first thing a new user does.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void TheDefaultServerUrl_IsTheAddressTheApiActuallyServes()
|
|
{
|
|
shell.ServerUrl.ShouldBe("http://localhost:5233");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("short", "short")]
|
|
[InlineData("a sufficiently long passphrase", "a different one")]
|
|
public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
|
|
{
|
|
await SignedInAsync();
|
|
|
|
shell.Passphrase = entered;
|
|
shell.ConfirmPassphrase = confirmation;
|
|
|
|
await shell.EnrollCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
server.EnrollmentCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheRecoveryCodeScreen_CannotBeSkipped()
|
|
{
|
|
// The only moment the code exists. Losing it along with the passphrase means the vault is
|
|
// unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
|
|
await EnrolledAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// Trying to continue without confirming gets nowhere.
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNull();
|
|
|
|
shell.RecoveryCodeWrittenDown = true;
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
|
|
// And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
|
|
// model for the rest of the session would undo that.
|
|
shell.RecoveryCode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AWrongPassphrase_KeepsTheVaultLocked()
|
|
{
|
|
await ReadyToUnlockAsync();
|
|
|
|
shell.Passphrase = "not the passphrase";
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Vault.ShouldBeNull();
|
|
shell.StatusMessage.ShouldContain("did not open");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingOpensTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
shell.Vault.ShouldNotBeNull();
|
|
shell.Vault.VaultName.ShouldBe("Personal");
|
|
|
|
// Cleared once used, so it is not sitting in a bound property for the rest of the session.
|
|
shell.Passphrase.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARestartUnlocksWithNoNetworkAtAll()
|
|
{
|
|
// The property the whole storage layer exists for, from the shell's point of view. The second
|
|
// shell is given a sign-in delegate that fails if called.
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
new UnavailableDeviceKeyStore(),
|
|
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
|
TimeProvider.System,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
|
|
offline.State.ShouldBe(ShellState.Locked);
|
|
offline.AccountName.ShouldBe("Alice Example");
|
|
offline.IsOnline.ShouldBeFalse();
|
|
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
offline.State.ShouldBe(ShellState.Unlocked);
|
|
offline.Vault.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
[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 vault.DeleteHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
|
|
// Pushed without a second action. A tombstone that sat in the outbox would let the item come back
|
|
// on a machine that synced in the meantime.
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locking does not drop the connection, so take a fresh shell that never signed in.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
new UnavailableDeviceKeyStore(),
|
|
(_, _) => throw new InvalidOperationException("unreachable"),
|
|
TimeProvider.System,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
var vault = offline.Vault!;
|
|
await AddHostAsync(vault, "offline-host");
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Offline");
|
|
vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
|
|
server.PushCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <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 vault.DeleteKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldBeEmpty();
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
// ---- One kind of item at a time ----
|
|
|
|
[Fact]
|
|
public async Task TheColumnOpensOnHostsAndTheSelectorMovesBetweenSections()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
// Hosts, because connecting is what somebody who has just unlocked a vault came to do. Keys and
|
|
// credentials exist to make that work, and neither is where the first click belongs.
|
|
vault.Section.ShouldBe(VaultSection.Hosts);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
vault.ShowsKeys.ShouldBeFalse();
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
|
|
|
vault.ShowsKeys.ShouldBeTrue();
|
|
vault.ShowsHosts.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
}
|
|
|
|
/// <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.Hosts;
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open in the hosts section");
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
vault.CancelEditCommand.Execute(null);
|
|
|
|
// And through the other door into each editor.
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.EditSelectedKeyCommand.Execute(null);
|
|
vault.ShowsKeys.ShouldBeTrue();
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The refusal that keeps the rule above true. Leaving the section while an editor is open would hide it,
|
|
/// and in the key editor's case that means a pasted private key sitting in a form with nothing on screen
|
|
/// to say it is there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SwitchingSectionIsRefusedWhileAnEditorIsOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
|
|
|
vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor");
|
|
vault.Status.ShouldContain("SSH key");
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
|
|
|
// A refusal, not a lockout: dealing with the editor releases the selector.
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
|
|
// Symmetrically, and with the message naming the editor that is actually open — which matters more
|
|
// here than it used to, because the thing to go back to may not be the section on screen.
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = "half-typed";
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
|
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
vault.Status.ShouldContain("host");
|
|
vault.EditorLabel.ShouldBe("half-typed");
|
|
}
|
|
|
|
/// <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.Hosts);
|
|
|
|
vault.Status.ShouldBeEmpty();
|
|
vault.IsEditing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One editor at a time, still — but no longer for the reason it was introduced for. Both editors used to
|
|
/// be <c>Auto</c> rows in one 340-pixel column whose combined height exceeded it; sections ended that, and
|
|
/// the layout suite now measures two open editors fitting. What the rule buys today is that an open key
|
|
/// editor is always one somebody can see, because it is holding their private key.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OnlyOneEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeFalse("the host editor must not open over the key editor");
|
|
vault.IsEditingKey.ShouldBeTrue();
|
|
vault.Status.ShouldContain("SSH key");
|
|
|
|
// The refusal is worth nothing if it costs the paste.
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
|
|
|
// And it is a refusal, not a lockout.
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeTrue();
|
|
|
|
// Symmetrically, with the host editor holding the column.
|
|
vault.EditorLabel = "half-typed";
|
|
vault.NewKeyCommand.Execute(null);
|
|
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
vault.EditorLabel.ShouldBe("half-typed");
|
|
vault.Status.ShouldContain("host");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAnExistingItem_IsRefusedByTheOtherEditorToo()
|
|
{
|
|
// The Edit commands are a second door into the same column, and guarding only the Add ones would
|
|
// leave it wide open.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.SelectedKey = vault.Keys[0];
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeFalse();
|
|
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditSelectedKeyCommand.Execute(null);
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
}
|
|
|
|
// ---- Binding a key to a host ----
|
|
|
|
[Fact]
|
|
public async Task BindingAKeyToAHost_RoundTripsThroughTheEditorAndTheVault()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys[0].EntityId;
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
|
|
vault.Hosts[0].Authentication.ShouldBe("key");
|
|
|
|
// Through the server and back, which is what makes it a property of the host rather than of this
|
|
// machine — the whole reason it is a payload field and not a local preference.
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
|
|
|
|
// And it is offered back correctly when the editor reopens, including as the current selection.
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(keyId);
|
|
vault.EditorAuthenticationChoices[0].Kind
|
|
.ShouldBe(AuthenticationKind.Typed, "the typed-password entry stays first");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWithNoKey_UsesThePassword()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
// A key exists in the vault and is even selected in the key list. An unbound host must still use
|
|
// the password: the list selection is for editing keys, not for deciding authentication.
|
|
await AddKeyAsync(vault, "deploy");
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.ConnectPassword = "typed-in";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential
|
|
.ShouldBeOfType<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 vault.DeleteKeyCommand.ExecuteAsync(null);
|
|
vault.Keys.ShouldBeEmpty();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.ConnectPassword = "must-not-be-sent";
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
|
vault.Status.ShouldContain("not in this vault");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostWhoseKeyHasBeenDeleted_DoesNotQuietlyUnbindIt()
|
|
{
|
|
// The same failure one step removed, and the subtler one. Someone opens the host to change its port;
|
|
// if the picker had silently fallen back to "no key", saving would convert it to password
|
|
// authentication and nothing would ever have said so.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys[0].EntityId;
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
// The binding is still there, still selected, and says what is wrong with it.
|
|
var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
|
|
selected.EntityId.ShouldBe(keyId);
|
|
selected.Kind.ShouldBe(AuthenticationKind.SshKey, "a missing key must not come back as a credential");
|
|
selected.Label.ShouldContain("no longer here");
|
|
|
|
vault.EditorPort = 2244;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
|
|
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId, "an unrelated edit must not drop the binding");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemovingABinding_PutsTheHostBackOnAPassword()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.Single(choice => choice.Kind is AuthenticationKind.Typed);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
vault.Hosts[0].Authentication.ShouldBe("password");
|
|
|
|
vault.ConnectPassword = "typed-in";
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<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 vault.DeleteCredentialCommand.ExecuteAsync(null);
|
|
|
|
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();
|
|
|
|
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheVaultSummaryCountsCredentialsToo()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Status.ShouldBe("1 host(s), 1 key(s), 1 credential(s) in Personal.");
|
|
|
|
// And a kind with nothing in it is left out rather than reported as zero.
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
|
}
|
|
|
|
/// <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 vault");
|
|
}
|
|
|
|
// ---- Authenticating with a stored credential ----
|
|
|
|
[Fact]
|
|
public async Task AHostBoundToACredential_SendsItsPasswordAndItsUsername()
|
|
{
|
|
// Both halves, and the username is the half that was easy to lose: a credential's whole reason for
|
|
// existing is that it describes an account once, and sending its password under the host's username is
|
|
// wrong in a way the server only reports as "authentication failed".
|
|
//
|
|
// The credential's username is deliberately *not* the host's — the host is on "deploy" — because the
|
|
// two being equal is what makes this assertion pass under an implementation that reads the wrong one.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "svc-deploy");
|
|
|
|
var credentialId = vault.Credentials[0].EntityId;
|
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credentialId);
|
|
vault.Hosts[0].Authentication.ShouldBe("credential");
|
|
|
|
vault.ConnectPassword = "should-not-be-used";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
|
request.Credential.ShouldBeOfType<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 vault.DeleteCredentialCommand.ExecuteAsync(null);
|
|
vault.Credentials.ShouldBeEmpty();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.ConnectPassword = "must-not-be-sent";
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
|
vault.Status.ShouldContain("credential that is not in this vault");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostWhoseCredentialHasBeenDeleted_DoesNotQuietlyUnbindIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
var credentialId = vault.Credentials[0].EntityId;
|
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
|
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
|
|
selected.EntityId.ShouldBe(credentialId);
|
|
selected.Kind.ShouldBe(
|
|
AuthenticationKind.Credential, "a missing credential must not come back as a missing key");
|
|
selected.Label.ShouldContain("no longer here");
|
|
|
|
vault.EditorPort = 2244;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
|
|
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
|
|
}
|
|
|
|
/// <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"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheCredentialEditorIsAlsoOneEditorAtATime()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.CredentialEditorPassword = "half-typed";
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeFalse("the host editor must not open over the credential editor");
|
|
vault.Status.ShouldContain("credential");
|
|
vault.CredentialEditorPassword.ShouldBe("half-typed");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
|
vault.ShowsCredentials.ShouldBeTrue("and the selector must not move away from it either");
|
|
|
|
vault.CancelCredentialEditCommand.Execute(null);
|
|
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
|
|
vault.ShowsHosts.ShouldBeTrue();
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
|
|
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,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
offline.IsOnline.ShouldBeFalse();
|
|
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
offline.State.ShouldBe(ShellState.Unlocked, offline.StatusMessage);
|
|
offline.CanForgetDevice.ShouldBeTrue();
|
|
|
|
await offline.ForgetDeviceCommand.ExecuteAsync(null);
|
|
|
|
offline.StatusMessage.ShouldContain("still lists it");
|
|
offline.CanForgetDevice.ShouldBeFalse();
|
|
server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
|
|
|
|
// The half that decides whether this machine may let itself in happened anyway.
|
|
await offline.LockCommand.ExecuteAsync(null);
|
|
await offline.StartAsync(Token);
|
|
|
|
offline.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears()
|
|
{
|
|
deviceKeys.IsAvailable = false;
|
|
|
|
await UnlockedAsync();
|
|
|
|
shell.CanRegisterDevice.ShouldBeFalse();
|
|
shell.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ADeclinedGesture_LeavesTheUnlockScreenUsable()
|
|
{
|
|
// The fallback that makes the whole thing safe to offer: a cancelled prompt changes the message and
|
|
// nothing else, and the passphrase still opens the vault.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
await shell.StartAsync(Token);
|
|
|
|
deviceKeys.Decline = true;
|
|
await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
}
|
|
|
|
private async Task UnlockedAsync()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
}
|
|
|
|
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = label;
|
|
vault.EditorHostname = "db.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private static async Task AddKeyAsync(
|
|
VaultViewModel vault,
|
|
string label,
|
|
string material = "MATERIAL",
|
|
string passphrase = "hunter2")
|
|
{
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorLabel = label;
|
|
vault.KeyEditorPrivateKey = PrivateKey(material);
|
|
vault.KeyEditorPassphrase = passphrase;
|
|
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <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>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;
|
|
}
|
|
}
|